Merge pull request 'Fix/development version history' (#8) from fix/development-version-history into main
build / build (push) Successful in 55s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-10-19 09:55:07 +08:00
135 changed files with 14226 additions and 42 deletions
+23
View File
@@ -1,3 +1,26 @@
# Optional independent source, disabled by default. Never use the app writer or
# substitute DATABASE_URL. Backend credential file must be a regular 0600 file.
DOBOTAI_OPS_READ_ENABLED=false
DOBOTAI_OPS_READ_DSN_FILE=
DOBOTAI_OPS_TENANT_KEY=
DOBOTAI_OPS_DIAL_MODE=direct
DOBOTAI_OPS_SSH_TARGET=
DOBOTAI_OPS_CONNECT_TIMEOUT=5s
DOBOTAI_OPS_QUERY_TIMEOUT=4s
DOBOTAI_OPS_MAX_CONNS=2
DOBOTAI_OPS_MIN_CONNS=0
# API and worker share this opt-in binding; no browser-selected paths/credentials.
# Runtime root is the read-only existing weekly_report directory (SDK/config and
# private secret file). Python must be compatible and already provide PyYAML.
# Container deployment requires approved runtime/script mounts; not auto-installed.
DOBOTAI_OPS_DIRECTORY_SYNC_ENABLED=false
DOBOTAI_OPS_DIRECTORY_SYNC_PYTHON=
DOBOTAI_OPS_DIRECTORY_SYNC_SCRIPT=
DOBOTAI_OPS_DIRECTORY_SYNC_RUNTIME_ROOT=
DOBOTAI_OPS_DIRECTORY_SYNC_SPOOL_ROOT=
DOBOTAI_OPS_DIRECTORY_SYNC_TIMEOUT=20m
APP_ADDR=:8080
DATABASE_URL=postgres://governance@postgres:5432/governance?sslmode=disable
PGPASSFILE=/run/secrets/pgpass
+310 -2
View File
@@ -4,7 +4,26 @@
### Added
- **「版本记录」页**(一级菜单第 5 项):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
- 问答记录新增“本周”快捷查询,默认展示北京时间本周数据;所选范围自动读取全部记录,校验完整数量后展示,不再需要分页或加载更多。固定展示完整字段,空列不再隐藏,中文原文直接展示、外语未翻译明确标记;保留源端有界传输、取消和失败保护。
- 问答记录新增年份与自然周选择,周次展示起止日期,支持跨年及每年实际周数;按北京时间周一至周日填入查询范围,默认本周且仅查询截至当前的数据。保留自定义时间与原查询按钮,浏览器时区不改变查询边界。
- 问答运营按已确认口径显示知识覆盖率、回答正确率及原始分子/分母、排除数量与构成。使用完整筛选范围和当前有效人工优先结论,精确规则命中的未覆盖可进入覆盖分母;部分覆盖不算完全覆盖,待确认/未评估/失效/不计入按对应指标排除,零分母显示“暂无可计算数据”。返回独立规则版本,AI 模型与绩效范围仍未接入。
- 问答详情支持治理侧人工复核及不可变审计:覆盖、正确性、产品分类、问题类型、根因与理由由管理员明确填写,操作人取登录身份;保存后独立读回复核历史。保留源端判断和自动规则,输入变化使旧人工结论失效,已观测的内容回退不自动恢复旧结论;并发修改冲突、重复请求不重复留档。
- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。基础数量与本期经确认新增的质量比例分开显示;不接 AI 模型、不定义绩效分母、不回写 DobotAI 原问答。
- 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。
- 问答运营按稳定身份关联当前治理通讯录,缺失部门/区域补全并保留源快照及目录/区域版本;空列判断包含完整筛选结果,不混合配置已变化的分页。新增完整时间/关键词范围的使用统计,问答计次、名册内人员去重,跨区/未匹配/身份缺失分列,不输出绩效结论;不回写 DobotAI 原表。
- 「问答运营 → 区域使用」支持管理员从网页发起持久化通讯录同步,重复请求重放、进行中操作合并;按服务器固定范围注册后台执行器,未接线或未检测到心跳时不创建新任务。界面显示真实任务状态、不编造百分比;失败保留旧快照与区域规则,同步完成不覆盖未保存草稿。旧执行器丢失租约后不能激活结果,重启接管和已提交快照的续跑使用原任务。
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。
- 区域归属表支持长部门路径换行,避免继承全局不换行规则后将生效区域与归属依据列挤出桌面面板。
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。基础使用统计见本期新增,不代表绩效验收;翻译及 AI 评估模型接入按用户要求暂缓。
- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估尚未接入,不标为第一阶段完成。
- 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。
- **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
- 新增只读接口 `GET /api/v1/build-info`(当前运行产物的构建身份)、`GET /api/v1/version-history`(快照游标分页,不返回伪造的 total)、`POST /api/v1/version-history/sync`(仅管理员手动同步,并发同步复用同一作业)。
- 新增迁移 `000046_version_history`(提交记录 + 同步状态):`commit_sha` 是唯一身份,**版本号不是唯一键** —— 两个提交算出同一个开发号时各占一行,用 SHA 区分。
- 「当前运行」只认运行产物注入的**完整 SHA 精确匹配**:匹配到就标该条;开发构建显式标注"开发构建";拿不到标识就显示"当前运行版本未知",**不拿最新 main 或最新 tag 顶替**;CI 成功不等于已部署。
@@ -18,6 +37,8 @@
### Fixed
- 修复通过带端口的 HTTP 地址访问问答运营时,正常同源操作被误报“拒绝跨站配置请求”:网页代理保留原始 Host 及端口,使缺少 Fetch Metadata 的浏览器也能正确执行 Origin 校验。保留后端跨站、异端口、伪造转发头与缺失操作意图的拒绝保护。
- **登记钉钉知识库失败(HTTP 409 `space_registration_conflict`)的真实原因与修复**:`source_connections.name` 有唯一约束,而早期路径登记的钉钉来源(`base_url` 为空、id 与 dws 现在推导的确定性 id 不同)被**归档后仍占着名字槽位** ⇒ 重新登记时 INSERT 撞唯一约束;处理器又把**所有**存储层错误都翻译成同一个 409,真实原因被吞掉(线上只能看到一个无细节的错误码)。现在:① 同名来源**已归档**时,把它改名腾位(`…(已下架 · <短ID>)`,归档行不显示、数据与审计一行不动);② 同名来源**仍在使用**时返回 `space_name_conflict` + 可行动提示(先「去掉来源」);③ 同一登记键指向别的空间且该行仍在用时返回 `space_identity_conflict`(已归档的行则允许就地改写 —— 这就是"去掉后再用 dws 重新获取");④ 其余内部故障改回 **500** `space_registration_failed`,不再伪装成冲突;前端优先显示后端 `hint`,并为这两个新码补了中文说明。
- 前端入口与静态资源的缓存语义("新功能看起来没上线"与"页面偶尔裸奔/空白"的根因):
@@ -52,9 +73,296 @@
> **版本怎么走**(两行就够):
> · **正式版本** = 下面那条 `## [x.y.z] - 日期`,是**发版事实**,由人批准后打注释 tag `vX.Y.Z`;
> · **开发版本自动递增** = `<正式版本的主.次>.<自那个 tag 以来的提交数>`(正式 `0.1.0` 之后第 12 个提交 =
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**,**不写进本文件** ⇒ 每个提交都有自己的版本号,
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
## 开发快照 snapshot/v0.1.27 - 2026-10-18
### Fixed
- 并行分支存在同号开发提交时,历史变更说明与补记标签计划按完整 SHA 选择,不再被前一条同号记录遮蔽;不强改推导版本号或移动既有标签。
## 开发快照 snapshot/v0.1.26 - 2026-10-18
### Fixed
- 版本记录收录已交付开发快照与当前运行提交,不再只同步 main;开发版本显示对应的独立变更说明。
- 修复同步重复返回旧已完成任务:进行中同步合并,完成后允许新一轮同步。
- 构建启动后自动请求版本同步;部署后独立读回当前完整 SHA、开发标签与变更说明,缺失时不宣称交付完成。
### Added
- 增加开发快照计划、显式发布及远端读回工具;历史说明按原提交补记,正式 tag 与 Release 保持独立授权。
## 开发快照 snapshot/v0.1.25 - 2026-10-18
目标提交:90901a4458054aca721047c9a2896f2130d46b5c
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答记录新增“本周”快捷查询,默认展示北京时间本周数据;所选范围自动读取全部记录,校验完整数量后展示,不再需要分页或加载更多。固定展示完整字段,空列不再隐藏,中文原文直接展示、外语未翻译明确标记;保留源端有界传输、取消和失败保护。
## 开发快照 snapshot/v0.1.24 - 2026-10-18
目标提交:3566f208e0e15fdec2cc98c29f93c46f5f7c91ca
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答记录新增年份与自然周选择,周次展示起止日期,支持跨年及每年实际周数;按北京时间周一至周日填入查询范围,默认本周且仅查询截至当前的数据。保留自定义时间与原查询按钮,浏览器时区不改变查询边界。
## 开发快照 snapshot/v0.1.23 - 2026-10-18
目标提交:2ea2111420e501fec37e0ea0c4bcd3cd8c866403
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 修复通过带端口的 HTTP 地址访问问答运营时,正常同源操作被误报“拒绝跨站配置请求”:网页代理保留原始 Host 及端口,使缺少 Fetch Metadata 的浏览器也能正确执行 Origin 校验。保留后端跨站、异端口、伪造转发头与缺失操作意图的拒绝保护。
## 开发快照 snapshot/v0.1.22 - 2026-10-18
目标提交:828c2c5fa0d4e3e0eee3987085a4d782872f7e95
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答运营按已确认口径显示知识覆盖率、回答正确率及原始分子/分母、排除数量与构成。使用完整筛选范围和当前有效人工优先结论,精确规则命中的未覆盖可进入覆盖分母;部分覆盖不算完全覆盖,待确认/未评估/失效/不计入按对应指标排除,零分母显示“暂无可计算数据”。返回独立规则版本,AI 模型与绩效范围仍未接入。
- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。基础数量与本期经确认新增的质量比例分开显示;不接 AI 模型、不定义绩效分母、不回写 DobotAI 原问答。
## 开发快照 snapshot/v0.1.21 - 2026-10-17
目标提交:4b3aaff3219e78ae3dfd5d12ba36f82e323a21ba
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答详情支持治理侧人工复核及不可变审计:覆盖、正确性、产品分类、问题类型、根因与理由由管理员明确填写,操作人取登录身份;保存后独立读回复核历史。保留源端判断和自动规则,输入变化使旧人工结论失效,已观测的内容回退不自动恢复旧结论;并发修改冲突、重复请求不重复留档。
- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。仅提供数量,不接 AI 模型、不定义质量率或绩效分母、不回写 DobotAI 原问答。
## 开发快照 snapshot/v0.1.20 - 2026-10-17
目标提交:9556db1ef01add21064553f64be6a7e25f838e9e
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。
## 开发快照 snapshot/v0.1.19 - 2026-10-17
目标提交:56b8cabed9213911b6beb4aeaac9baa22e872a00
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答运营按稳定身份关联当前治理通讯录,缺失部门/区域补全并保留源快照及目录/区域版本;空列判断包含完整筛选结果,不混合配置已变化的分页。新增完整时间/关键词范围的使用统计,问答计次、名册内人员去重,跨区/未匹配/身份缺失分列,不输出绩效结论;不回写 DobotAI 原表。
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。基础使用统计见本期新增,不代表绩效验收;翻译及 AI 评估模型接入按用户要求暂缓。
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估尚未接入,不标为第一阶段完成。
## 开发快照 snapshot/v0.1.18 - 2026-10-17
目标提交:cc4906b6a9f33683a5c0017d83b24d96d5c997b1
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 「问答运营 → 区域使用」支持管理员从网页发起持久化通讯录同步,重复请求重放、进行中操作合并;按服务器固定范围注册后台执行器,未接线或未检测到心跳时不创建新任务。界面显示真实任务状态、不编造百分比;失败保留旧快照与区域规则,同步完成不覆盖未保存草稿。旧执行器丢失租约后不能激活结果,重启接管和已提交快照的续跑使用原任务。
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件;问答组织关联尚未接入。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。
## 开发快照 snapshot/v0.1.17 - 2026-10-17
目标提交:6adfdcdd4fe0e5c35018bf0e4857c203dd24e5d1
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 区域归属表支持长部门路径换行,避免继承全局不换行规则后将生效区域与归属依据列挤出桌面面板。
## 开发快照 snapshot/v0.1.16 - 2026-10-17
目标提交:5874134e049b0896a202b41d9d400d1be5386f89
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件;网页触发的后台同步队列及问答组织关联尚未接入。
## 开发快照 snapshot/v0.1.15 - 2026-10-17
目标提交:dab74b706b5b87da3ecb71a8e0f6b6caa5fba4ad
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是实时通讯录同步或使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。
- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。
## 开发快照 snapshot/v0.1.14 - 2026-10-17
目标提交:fd04bf7886aa4eba68b247dd7c1092894a5282da
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估、人员区域映射尚未接入,不标为第一阶段完成。
- 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。
- **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
## 开发快照 snapshot/v0.1.13 - 2026-10-14
目标提交:c1d49adb622f3bf18e51bddb415795a894ed227e
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 补三Agent交接闭环工作流(状态机、快照tag与验收模板)。
## 开发快照 snapshot/v0.1.12 - 2026-10-14
目标提交:9a8a3e77956149d116360bbb561efd67e20fb062
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- **登记钉钉知识库失败(HTTP 409 `space_registration_conflict`)的真实原因与修复**:`source_connections.name` 有唯一约束,而早期路径登记的钉钉来源(`base_url` 为空、id 与 dws 现在推导的确定性 id 不同)被**归档后仍占着名字槽位** ⇒ 重新登记时 INSERT 撞唯一约束;处理器又把**所有**存储层错误都翻译成同一个 409,真实原因被吞掉(线上只能看到一个无细节的错误码)。现在:① 同名来源**已归档**时,把它改名腾位(`…(已下架 · <短ID>)`,归档行不显示、数据与审计一行不动);② 同名来源**仍在使用**时返回 `space_name_conflict` + 可行动提示(先「去掉来源」);③ 同一登记键指向别的空间且该行仍在用时返回 `space_identity_conflict`(已归档的行则允许就地改写 —— 这就是"去掉后再用 dws 重新获取");④ 其余内部故障改回 **500** `space_registration_failed`,不再伪装成冲突;前端优先显示后端 `hint`,并为这两个新码补了中文说明。
## 开发快照 snapshot/v0.1.11 - 2026-10-14
目标提交:2c7265efc47a2cd6b324bf278fa5ddb0c4a9fe57
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 「系统设置 → 数据源配置」**收敛成两个入口**:**dws 导入**(按当前钉钉账号列出可访问的知识库与钉盘空间)与 **PP 导入**。AList(已由钉盘文件替代)、静态登记的钉钉知识库、本地手工导入与 PP legacy 都**不再出现在这一页**;已接入的这类来源用**归档**下架(`POST /api/v1/source-connections/{id}/archive`,管理员、幂等,带写意图头与幂等键):从列表、来源与目录、计划刷新与手动扫描里消失,**目录快照、发布记录与审计数据一行不删**。恢复用 `…/restore`(保持停用、不自动带回调度),或让 dws 重新登记同一来源 —— 钉钉空间登记是确定性 ID + `ON CONFLICT`,**原地复活同一行、不产生重复**。卡片上新增「去掉来源」按钮(管理员,带确认弹窗)。
- 迁移 `000047_source_connection_archive`:新增 `archived_at` / `archived_by` 与只覆盖未归档行的部分索引;列表、目录、计划刷新、手动扫描四类查询一律排除归档行(漏一条就等于入口没去掉)。
## 开发快照 snapshot/v0.1.10 - 2026-10-14
目标提交:64b32c2da64881b3c9bf6de6b8f9af3f833128fd
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 「系统设置 → 数据源配置」默认只列 PP 资料与「可加入的来源」:AList 来源(已由钉盘文件替代)与静态登记的钉钉知识库来源(改为按当前钉钉账号发现)**不再默认列出**,收进「显示 AList/钉钉知识库来源(N)」折叠开关 —— **来源一个都没删**,展开后照旧可改刷新周期与启停,「来源与目录」里的内容不变。手动导入来源(本地手工验收)不在这次范围内,照旧显示。
## 开发快照 snapshot/v0.1.9 - 2026-10-14
目标提交:b7f7e4f12101569db5bb76af9d836043eefc342b
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- **上线前护栏 + 事故复盘**:`scripts/deploy-api-web.sh` 现在先校验 `.env` 的键值形状(值里再出现 `KEY=` 或键名不合法 ⇒ **拒绝上线**),配置校验失败时也会给出"两行粘成一行"的线索。事故:`.env` 最后一行没有结尾换行、又被追加新键 ⇒ 两行粘成一行 ⇒ `VERSION_HISTORY_GITEA_BASE_URL` 变成非法值 ⇒ api/worker 按设计拒绝启动、崩溃重启(平台整体不可用)。已修数据、加护栏、加回归用例。
## 开发快照 snapshot/v0.1.8 - 2026-10-14
目标提交:4cb51108454c3559e8a6305ad3316f74ed4e2daf
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 「版本记录」页的链接改为**抓取与链接分离**:抓取仍走配置的(内网)地址,页面上的「查看提交 / 查看 tag」用 `VERSION_HISTORY_LINK_ORIGIN`(公网 origin)拼 —— 原先两者共用内网地址,内网外的浏览器点开是空白;另外 tag 记录原先不带链接(空 `href` 点下去没反应),现在补上链接、没有链接就不渲染该按钮。
## 开发快照 snapshot/v0.1.7 - 2026-10-14
目标提交:90db243a68a5d16d643dd0273614b93666d65ca7
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 前端入口与静态资源的缓存语义("新功能看起来没上线"与"页面偶尔裸奔/空白"的根因):
## 开发快照 snapshot/v0.1.6 - 2026-10-14
目标提交:062a3b3cb60ede5ebcca64a0f14df53fffa1f359
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 前端入口的缓存策略:`index.html` 此前没有缓存头,CDN/浏览器会启发式缓存它,于是**页面会一直停在旧构建上**(实测被腾讯云 EdgeOne 卡住:源站已是新包,公网入口仍发旧包,看起来像"新菜单/新页面没上线")。现在 `index.html` 每次回源校验(`no-cache, must-revalidate`)、带内容哈希的 `/assets/` 长缓存(`immutable`),旧哈希资源明确 404 而不是回退成 HTML。
## 开发快照 snapshot/v0.1.5 - 2026-10-14
目标提交:4d43a9386a8a97969a1568956213f6f17b13edfb
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- tag 之前的提交不再被编号 + 部署脚本注入构建身份。
## 开发快照 snapshot/v0.1.4 - 2026-10-13
目标提交:0974d8dfa2aad5650eefbcee3edb365a8afd4868
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- **「版本记录」页**(一级菜单第 5 项):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
- 新增只读接口 `GET /api/v1/build-info`(当前运行产物的构建身份)、`GET /api/v1/version-history`(快照游标分页,不返回伪造的 total)、`POST /api/v1/version-history/sync`(仅管理员手动同步,并发同步复用同一作业)。
- 新增迁移 `000046_version_history`(提交记录 + 同步状态):`commit_sha` 是唯一身份,**版本号不是唯一键** —— 两个提交算出同一个开发号时各占一行,用 SHA 区分。
- 「当前运行」只认运行产物注入的**完整 SHA 精确匹配**:匹配到就标该条;开发构建显式标注"开发构建";拿不到标识就显示"当前运行版本未知",**不拿最新 main 或最新 tag 顶替**;CI 成功不等于已部署。
### Security
- 版本记录同步**匿名只读**远端仓库(**不使用任何凭据**),来源限配置里批准的 origin 与固定仓库:拒绝任意 URL、拒绝跨主机重定向,单个响应设大小上限与超时、5xx/429 才有退避重试。
- 提交正文与变更记录一律按**纯文本**渲染(不产生 HTML/脚本注入面);页面不含打 tag / 发布 / 部署入口;同步动作仅管理员可用,服务端再次判角色。
## 开发快照 snapshot/v0.1.3 - 2026-10-13
目标提交:ad9c5d20d467a077e88bbfe63b1a56355d263e51
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- Merge remote-tracking branch 'origin/main'。
## 开发快照 snapshot/v0.1.1 - 2026-10-13
目标提交:3a1d32da8fba90eb5081865b1f3ad424a46639ee
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 重写平台定位与能力边界并归档原说明。
## 开发快照 snapshot/v0.1.1 - 2026-10-13
目标提交:950c1b429bc814d9fa7c2c1449f999a05537af21
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 抬档门禁(破坏性 ⇒ 0.x 抬次 / ≥1.0.0 抬主)+ 1.0.0 前置(稳定契约承诺)。
## [0.1.0] - 2026-10-13
### Added
+115 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Knowledge Governance API",
"version": "v1",
"description": "独立知识治理控制平面API。所有响应使用安全投影;不返回来源凭据、签名下载地址、原始内容、内部目标连接或生产密钥。"
"description": "独立知识治理控制平面API。响应不返回来源凭据、内部目标连接或生产密钥。问答运营仅向已登录且获授权的管理员返回业务问答正文,禁止共享缓存。"
},
"servers": [
{
@@ -44,6 +44,120 @@
}
],
"paths": {
"/api/v1/qa-ops/organization/sync": {
"get": {"tags":["QA operations"],"operationId":"getQAOrganizationSync","summary":"管理员读取通讯录同步执行器状态及最近任务的安全汇总,不返回人员名单或运行配置","responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"状态存储不可用,不伪装为空记录"}}},
"post": {"tags":["QA operations"],"operationId":"startQAOrganizationSync","summary":"管理员同源发起持久化通讯录同步;重复请求重放同一任务,进行中任务合并;失败保留现有快照","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false}}}},"responses":{"202":{"$ref":"#/components/responses/OperationEnvelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源操作"},"409":{"description":"操作标识与原请求冲突"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"同步未接线、执行器离线或入队失败;未创建新任务"}}}
},
"/api/v1/qa-ops/organization": {
"get": {
"tags": ["QA operations"],
"operationId": "getQAOrganization",
"summary": "管理员读取服务器固定租户的完整部门路径、已保存区域归属与快照时间,不返回人员名单",
"responses": {
"200": {"$ref": "#/components/responses/Envelope"},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"description": "需要登录"},
"403": {"description": "仅管理员可访问"},
"500": {"$ref": "#/components/responses/InternalError"},
"503": {"description": "治理侧组织存储不可用"}
}
}
},
"/api/v1/qa-ops/organization/preview": {
"post": {
"tags": ["QA operations"],
"operationId": "previewQAOrganizationRegions",
"summary": "预览当前通讯录版本下的父子部门区域归属和去重人数,不保存修改",
"parameters": [{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],
"requestBody": {"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["directory_id","base_revision","rules"],"properties":{"directory_id":{"type":"string"},"base_revision":{"type":"string"},"rules":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["department_id","region"],"properties":{"department_id":{"type":"string"},"region":{"enum":["国内工业","APAC","EMEA","America"]}}}}}}}}},
"responses": {
"200": {"$ref": "#/components/responses/Envelope"},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"description": "需要登录"},
"403": {"description": "仅管理员同源操作可访问"},
"409": {"description": "通讯录或配置版本已变化,需重新读取"},
"500": {"$ref": "#/components/responses/InternalError"},
"503": {"description": "治理侧组织存储不可用"}
}
}
},
"/api/v1/qa-ops/organization/regions": {
"put": {
"tags": ["QA operations"],
"operationId": "saveQAOrganizationRegions",
"summary": "保存版本化区域配置;操作人取管理员会话,不改变钉钉或DobotAI数据",
"parameters": [{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],
"requestBody": {"$ref":"#/paths/~1api~1v1~1qa-ops~1organization~1preview/post/requestBody"},
"responses": {
"200": {"$ref": "#/components/responses/Envelope"},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"description": "需要登录"},
"403": {"description": "仅管理员同源操作可访问"},
"409": {"description": "版本冲突或尚无完整通讯录"},
"500": {"$ref": "#/components/responses/InternalError"},
"503": {"description": "治理侧组织存储不可用"}
}
}
},
"/api/v1/qa-ops/usage/people": {"post": {"tags": ["QA operations"], "operationId": "queryQAOperationsMonthlyPeople", "summary": "按北京时间自然月列出当前名册人员的2.0用量(含零记录),仅管理员,不返回原始员工标识,不作为在职/考核名单", "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "完整名册缺失或目录/区域版本变化,请同步/重新查询,不返回伪零"}}, "description": "只读查询,不写治理库或生产库;姓名筛选置于 JSON 请求体,不进入 URL;整名册指标不随人员筛选变化。第2页起须带首页目录、区域版本及 end 截止。", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "required": ["month"], "properties": {"month": {"type": "string", "pattern": "^(?!0000)[0-9]{4}-(0[1-9]|1[0-2])$"}, "cutoff": {"type": "string", "format": "date-time"}, "region": {"type": "string", "enum": ["国内工业", "APAC", "EMEA", "America", "unassigned", "cross_region"]}, "department_id": {"type": "string"}, "activity": {"type": "string", "enum": ["all", "used", "zero"]}, "search": {"type": "string", "maxLength": 100}, "page": {"type": "integer", "minimum": 1, "maximum": 100000, "default": 1}, "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 30}, "directory_id": {"type": "string"}, "revision_id": {"type": "string"}}}}}}, "parameters": [{"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-usage-read"]}}]}},
"/api/v1/qa-ops/usage": {"get": {"tags": ["QA operations"], "operationId": "getQAOperationsUsage", "summary": "完整筛选范围内按当前通讯录和区域版本回看2.0问答使用;固定租户、管理员访问,不输出绩效结论", "parameters": [{"name": "start", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "end", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "keyword", "in": "query", "schema": {"type": "string", "maxLength": 200}}], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}}}},
"/api/v1/qa-ops/quality/query": {"post": {"operationId": "queryQAQualityCounts", "summary": "按完整筛选范围读取指纹/规则元数据并留档统计,最多5000条;summary.rates 返回 quality-rates-human-first-v1、原始分子/分母、百分比与分母构成。覆盖率=covered/(covered+uncovered+partial),正确率=covered_correct/(covered_correct+covered_incorrect);失效/不计入/相应待确认与未评估排除,零分母percent=null。原文仅在读取问答/复核时采集", "parameters": [{"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-quality"]}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"start": {"type": "string", "format": "date-time"}, "end": {"type": "string", "format": "date-time"}, "keyword": {"type": "string", "maxLength": 200}}, "required": ["start", "end"]}}}}, "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}},
"/api/v1/qa-ops/records/{id}/reviews": {"post": {"operationId": "saveQAQualityReview", "summary": "治理库追加人工复核,登录身份审计,源端只读,输入/复核乐观锁和nonce幂等;正确时根因为空,不计入须说明原因", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-quality"]}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"input_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "input_epoch": {"type": "integer", "minimum": 1}, "base_revision": {"type": "integer", "minimum": 0}, "nonce": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, "fields": {"type": "object", "additionalProperties": false, "properties": {"coverage": {"type": "string", "enum": ["covered", "uncovered", "partial", "pending", "excluded"]}, "correctness": {"type": "string", "enum": ["correct", "incorrect", "pending"]}, "product_category": {"type": "string", "maxLength": 128}, "problem_type": {"type": "string", "maxLength": 128}, "root_cause": {"type": "string", "maxLength": 2000}, "exclusion_reason": {"type": "string", "maxLength": 2000}}, "required": ["coverage", "correctness"]}, "reason": {"type": "string", "minLength": 1, "maxLength": 4000}}, "required": ["input_hash", "input_epoch", "base_revision", "nonce", "fields", "reason"]}}}}, "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}},
"/api/v1/qa-ops/records/{id}/quality/history": {"get": {"operationId": "listQAQualityHistory", "summary": "按当前固定租户查询不可变复检/人工历史,分别倒序分页,各20条", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "review_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}, {"name": "check_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}},
"/api/v1/qa-ops/records/{id}/quality/inputs/{epoch}": {"get": {"operationId": "getQAQualityInputSnapshot", "summary": "管理员读取已采集的历史质量输入快照,不另存人员关联/反馈字段;元数据留档但未采集原文返回409,不编造空快照", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "epoch", "in": "path", "required": true, "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "该输入版本仅有指纹/规则元数据、原文未采集"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}},
"/api/v1/qa-ops/status": {
"get": {
"tags": ["QA operations"],
"operationId": "getQAOperationsStatus",
"summary": "读取独立问答只读数据源状态与安全告警,不返回连接信息;需要管理员会话",
"responses": {
"200": {"$ref": "#/components/responses/Envelope"},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"description": "需要登录"},
"403": {"description": "未获员工问答查看授权"},
"500": {"$ref": "#/components/responses/InternalError"}
}
}
},
"/api/v1/qa-ops/records": {
"get": {
"tags": ["QA operations"],
"operationId": "listQAOperationsRecords",
"summary": "按半开提问时间范围、原问题关键词、稳定游标列出钉钉问答;租户由后端固定;答案包含暂无已发布则治理侧未覆盖",
"parameters": [
{"name": "start", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}},
{"name": "end", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}},
{"name": "keyword", "in": "query", "schema": {"type": "string", "maxLength": 200}},
{"name": "cursor", "in": "query", "schema": {"type": "string", "maxLength": 2048}},
{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}}
],
"responses": {
"200": {"$ref": "#/components/responses/Envelope"},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"description": "需要登录"},
"403": {"description": "未获员工问答查看授权"},
"503": {"description": "源库未接入、不可用或权限护栏阻断,不返回空列表伪装成功"},
"500": {"$ref": "#/components/responses/InternalError"}
}
}
},
"/api/v1/qa-ops/records/{id}": {
"get": {
"tags": ["QA operations"],
"operationId": "getQAOperationsRecord",
"summary": "读取授权租户内的完整问答与源端覆盖对照;治理覆盖规则独立,不写回源库",
"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "maxLength": 256}}],
"responses": {
"200": {"$ref": "#/components/responses/Envelope"},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"description": "需要登录"},
"403": {"description": "未获员工问答查看授权"},
"404": {"description": "当前授权范围内不存在此问答"},
"503": {"description": "独立只读源不可用或权限校验阻断"},
"500": {"$ref": "#/components/responses/InternalError"}
}
}
},
"/api/v1/build-info": {
"get": {
"tags": [
+36
View File
@@ -20,7 +20,10 @@ import (
"knowledge-governance/internal/monitoring"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/platform/weknora"
"knowledge-governance/internal/qaopsorg"
"knowledge-governance/internal/qaopsquality"
"knowledge-governance/internal/serviceverify"
"knowledge-governance/internal/version"
)
// dingtalkSourceLister 是「系统设置 → 数据源配置」的数据源发现实现:只读列举钉钉知识库与
@@ -35,6 +38,9 @@ func (dingtalkSourceLister) ListAvailableDingtalkSources(ctx context.Context) (d
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if len(os.Args) == 2 && os.Args[1] == "--version-record-status" {
os.Exit(versionRecordStatus(ctx, os.Getenv))
}
if err := run(ctx, os.Getenv); err != nil {
log.Fatal(err)
}
@@ -124,8 +130,38 @@ func run(ctx context.Context, getenv func(string) string) error {
versionHistoryStore, versionHistoryRepo := httpapi.VersionHistoryStore(nil), ""
if config.VersionHistoryGiteaBaseURL != "" {
versionHistoryStore, versionHistoryRepo = postgresstore.NewStore(pool), config.VersionHistoryRepo
info := version.Snapshot()
if info.IdentityAvailable && info.CommitSHA != nil {
if _, e := actionStore.EnqueueVersionHistorySync(ctx, config.VersionHistoryRepo, "system:deployment", *info.CommitSHA, time.Now()); e != nil {
log.Print("version history deployment synchronization could not be queued")
}
}
}
qaReader, qaSettings := openQAOperations(ctx, getenv, config.DatabaseURL)
if closer, ok := qaReader.(interface{ Close() }); ok {
defer closer.Close()
}
if qaSettings.Problem != "" {
log.Print("QA operations source is unavailable; governance remains online")
}
qaOrganizationStore := qaopsorg.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY"))
qaSyncSettings, qaSyncErr := qaopsorg.LoadSyncSettings(getenv)
if qaSyncErr != nil {
log.Print("QA directory synchronization is not configured; governance remains online")
}
var qaSyncStore httpapi.QAOrgSyncStore
if getenv("DOBOTAI_OPS_TENANT_KEY") != "" {
qaSyncStore = qaOrganizationStore
}
handler, err := httpapi.NewHandler(httpapi.Config{
QAQuality: qaopsquality.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY")),
QAOrgSync: qaSyncStore,
QAOrgSyncEnabled: qaSyncErr == nil && qaSyncSettings.Enabled,
QAOrg: qaOrganizationStore,
QAOps: qaReader,
QAOpsTenantKey: qaSettings.TenantKey,
QAOpsEnabled: qaSettings.Enabled,
QAOpsProblem: qaSettings.Problem,
Store: postgresstore.NewStore(pool),
Monitor: monitorProvider,
InternalRoutingKnowledgeBaseID: config.InternalRoutingKnowledgeBaseID,
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"context"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/httpapi"
)
// A failed optional source must not replace or take down the main database.
func openQAOperations(ctx context.Context, getenv func(string) string, governanceDSN string) (httpapi.QAOpsReader, dobotaiops.Settings) {
settings := dobotaiops.LoadSettings(getenv, governanceDSN)
if !settings.Enabled || settings.Problem != "" {
return nil, settings
}
client, err := dobotaiops.Build(ctx, settings.Connection)
if err != nil {
settings.Problem = "source_connection_or_permissions_failed"
return nil, settings
}
return client, settings
}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"context"
"testing"
)
func TestQAOpsConnectionFailureIsContainedOutsideMainDatabase(t *testing.T) {
reader, settings := openQAOperations(context.Background(), func(string) string { return "" }, "postgres://main@host/governance")
if reader != nil || settings.Enabled || settings.Problem != "" {
t.Fatal("QA operations must be disabled by default")
}
env := map[string]string{"DOBOTAI_OPS_READ_ENABLED": "true", "DOBOTAI_OPS_TENANT_KEY": "approved-tenant", "DOBOTAI_OPS_READ_DSN_FILE": "/nonexistent-qaops-fixture-secret"}
reader, settings = openQAOperations(context.Background(), func(k string) string { return env[k] }, "postgres://main@host/governance")
if reader != nil || !settings.Enabled || settings.Problem == "" {
t.Fatal("failed external connection must be a contained module error")
}
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/version"
"os"
"time"
)
// Operator-only read-back in the existing API binary; no new public endpoint,
// no session/credential creation and no migrations or remote forge access.
func versionRecordStatus(ctx context.Context, getenv func(string) string) int {
info := version.Snapshot()
if !info.IdentityAvailable || info.CommitSHA == nil {
return printVersionStatus(false, "build_identity_missing", "")
}
config, e := appconfig.Load(getenv)
if e != nil {
return printVersionStatus(false, "configuration_invalid", *info.CommitSHA)
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
pool, e := pgxpool.New(ctx, config.DatabaseURL)
if e != nil {
return printVersionStatus(false, "database_unavailable", *info.CommitSHA)
}
defer pool.Close()
tag := "snapshot/v" + info.DisplayVersion
kind := "development"
if info.BuildKind == "release" {
tag = "v" + info.DisplayVersion
kind = "release"
}
evidence, _ := json.Marshal([]map[string]any{{"name": tag, "annotated": true}})
var ready bool
e = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM version_history_commits c WHERE repo_key=$1 AND commit_sha=$2 AND version_label=$3 AND record_kind=$4 AND btrim(release_notes)<>'' AND release_tags @> $5::jsonb AND snapshot_id=(SELECT max(id) FROM version_history_sync_runs WHERE repo_key=$1 AND status='completed' AND complete))`, config.VersionHistoryRepo, *info.CommitSHA, info.DisplayVersion, kind, evidence).Scan(&ready)
if e != nil {
return printVersionStatus(false, "database_unavailable", *info.CommitSHA)
}
if !ready {
return printVersionStatus(false, "version_record_pending", *info.CommitSHA)
}
return printVersionStatus(true, "verified", *info.CommitSHA)
}
func printVersionStatus(ready bool, code, sha string) int {
raw, _ := json.Marshal(map[string]any{"ready": ready, "code": code, "commit_sha": sha})
fmt.Fprintln(os.Stdout, string(raw))
if ready {
return 0
}
return 1
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/qaopsorg"
)
func main() {
if err := run(os.Args[1:], os.Getenv, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "directory import failed; check private input and governance connection (details withheld)")
os.Exit(1)
}
}
func run(args []string, getenv func(string) string, out io.Writer) error {
if len(args) != 1 || getenv("QAOPS_CONFIRM_DIRECTORY_IMPORT") != "import-governance-directory" || getenv("DOBOTAI_OPS_TENANT_KEY") == "" || getenv("DATABASE_URL") == "" {
return errors.New("explicit input, tenant, governance database and confirmation required")
}
info, err := os.Lstat(args[0])
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 16<<20 {
return errors.New("input must be a private regular JSON file")
}
file, err := os.Open(args[0])
if err != nil {
return errors.New("input unavailable")
}
defer file.Close()
decoder := json.NewDecoder(io.LimitReader(file, (16<<20)+1))
decoder.DisallowUnknownFields()
var input qaopsorg.Import
if decoder.Decode(&input) != nil || decoder.Decode(&struct{}{}) != io.EOF {
return errors.New("input invalid")
}
if !input.Complete {
return qaopsorg.ErrInvalid
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, getenv("DATABASE_URL"))
if err != nil {
return qaopsorg.ErrUnavailable
}
defer pool.Close()
store := qaopsorg.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY"))
saved, err := store.ReplaceDirectory(ctx, input)
if err != nil {
return err
}
active, err := store.GetView(ctx)
if err != nil || active.DirectoryID != saved.DirectoryID {
return qaopsorg.ErrConflict
}
return json.NewEncoder(out).Encode(map[string]any{"directory_id": active.DirectoryID, "source": active.DirectorySource, "synced_at": active.DirectorySyncedAt, "departments": len(active.View.Departments), "people": active.View.TotalPeople, "readback_verified": true})
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestDirectoryImportRequiresExplicitGovernanceConfirmationAndPrivateInput(t *testing.T) {
var output bytes.Buffer
if run(nil, func(string) string { return "" }, &output) == nil || output.Len() != 0 {
t.Fatal("unconfigured importer must not connect or report success")
}
path := filepath.Join(t.TempDir(), "directory.json")
if os.WriteFile(path, []byte(`{"complete":false}`), 0644) != nil {
t.Fatal("fixture creation failed")
}
env := func(k string) string {
return map[string]string{"QAOPS_CONFIRM_DIRECTORY_IMPORT": "import-governance-directory", "DOBOTAI_OPS_TENANT_KEY": "fixture", "DATABASE_URL": "postgres://invalid.invalid/no_connection_expected"}[k]
}
if run([]string{path}, env, &output) == nil {
t.Fatal("public personnel input accepted")
}
if os.Chmod(path, 0600) != nil {
t.Fatal("fixture chmod failed")
}
if run([]string{path}, env, &output) == nil || output.Len() != 0 {
t.Fatal("partial sync accepted or importer reported fabricated success")
}
}
+13 -1
View File
@@ -53,6 +53,9 @@ func run(ctx context.Context, getenv func(string) string) error {
if err != nil {
return err
}
if syncErr := configureDirectorySync(pool, getenv, handlers); syncErr != nil {
log.Print("QA directory synchronization unavailable; handler was not registered")
}
worker, err := jobs.NewWorker(jobs.WorkerConfig{
Queue: store,
WorkerID: config.WorkerID,
@@ -134,11 +137,20 @@ func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[st
triggeredBy := ""
var payload struct {
TriggeredBy string `json:"triggered_by"`
TargetSHA string `json:"target_sha"`
}
if err := json.Unmarshal(job.Payload, &payload); err == nil {
triggeredBy = payload.TriggeredBy
}
run, err := syncer.Sync(ctx, job.ID, triggeredBy)
activeSyncer := syncer
if payload.TargetSHA != "" {
var e error
activeSyncer, e = versionhistory.NewSyncer(versionhistory.SyncerConfig{Source: client, Store: store, RepoKey: config.VersionHistoryRepo, MaxPages: config.VersionHistoryMaxPages, TargetSHAs: []string{payload.TargetSHA}})
if e != nil {
return e
}
}
run, err := activeSyncer.Sync(ctx, job.ID, triggeredBy)
if err != nil {
log.Printf("version history sync failed job=%s run=%d code=%s: %v", job.ID, run.ID, run.ErrorCode, err)
return err
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/qaopsorg"
)
func configureDirectorySync(pool *pgxpool.Pool, getenv func(string) string, handlers map[string]jobs.Handler) error {
settings, err := qaopsorg.LoadSyncSettings(getenv)
if err != nil {
return err
}
if !settings.Enabled {
return nil
}
if err = settings.CheckWorkerRuntime(); err != nil {
return err
}
store := qaopsorg.NewStore(pool, settings.Tenant)
handlers[store.SyncJobType()] = func(ctx context.Context, job jobs.Job) error {
return store.HandleDirectorySync(ctx, job, settings.Runner)
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/qaopsorg"
"os"
"path/filepath"
"testing"
)
func TestDirectorySyncWorkerRegistrationIsOptInAndRuntimeChecked(t *testing.T) {
handlers := map[string]jobs.Handler{}
values := map[string]string{}
get := func(k string) string { return values[k] }
if err := configureDirectorySync(nil, get, handlers); err != nil || len(handlers) != 0 {
t.Fatal("disabled sync registered")
}
values["DOBOTAI_OPS_DIRECTORY_SYNC_ENABLED"] = "true"
if err := configureDirectorySync(nil, get, handlers); err == nil {
t.Fatal("incomplete binding registered")
}
root := t.TempDir()
script := filepath.Join(root, "fixture.py")
if err := os.WriteFile(script, []byte("# fixture; not executed by registration"), 0600); err != nil {
t.Fatal(err)
}
values["DOBOTAI_OPS_TENANT_KEY"] = "fixture-tenant"
values["DOBOTAI_OPS_DIRECTORY_SYNC_PYTHON"] = "/bin/sh"
values["DOBOTAI_OPS_DIRECTORY_SYNC_SCRIPT"] = script
values["DOBOTAI_OPS_DIRECTORY_SYNC_RUNTIME_ROOT"] = root
values["DOBOTAI_OPS_DIRECTORY_SYNC_SPOOL_ROOT"] = root
if err := configureDirectorySync(nil, get, handlers); err != nil || handlers[qaopsorg.NewStore(nil, "fixture-tenant").SyncJobType()] == nil {
t.Fatal("configured worker was not wired")
}
values["DOBOTAI_OPS_DIRECTORY_SYNC_SCRIPT"] = filepath.Join(root, "missing.py")
if err := configureDirectorySync(nil, get, map[string]jobs.Handler{}); err == nil {
t.Fatal("missing runtime advertised as ready")
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ server {
set $api_upstream http://api:8080;
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -22,7 +22,7 @@ server {
location = /health {
set $health_upstream http://api:8080;
proxy_pass $health_upstream/health;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
}
# 带内容哈希的构建产物:可以长期缓存(换版本必然换文件名);旧哈希文件已被新构建替换
+159
View File
@@ -0,0 +1,159 @@
package dobotaiops
import (
"context"
"fmt"
"strings"
)
// aclProbeSQL 只用目录/ACL 函数判断"这个连接是不是最小只读":
// - 角色属性:SUPER/CREATEROLE/CREATEDB/BYPASSRLS/REPLICATION
// - 库/模式级:public schema CREATE、当前库 CREATE
// - 继承面:角色成员关系数、可 SET ROLE 到的角色数
// - 所有权:该角色拥有的 relation / schema 数
// - "直接 ACL 只读但有效权限可写"的风险面:可执行的 SECURITY DEFINER 函数数
// - 四张目标表的 SELECT 与写权限
//
// 它不改任何东西,也不尝试任何写语句(工单明确禁止用"写后回滚"证明只读)。
const aclProbeSQL = `
SELECT current_user,
COALESCE((SELECT rolsuper FROM pg_roles WHERE rolname = current_user), FALSE),
COALESCE((SELECT rolcreaterole FROM pg_roles WHERE rolname = current_user), FALSE),
COALESCE((SELECT rolcreatedb FROM pg_roles WHERE rolname = current_user), FALSE),
COALESCE((SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user), FALSE),
COALESCE((SELECT rolreplication FROM pg_roles WHERE rolname = current_user), FALSE),
has_schema_privilege(current_user, 'public', 'CREATE'),
has_database_privilege(current_user, current_database(), 'CREATE'),
(SELECT count(*)::int FROM pg_auth_members WHERE member = (SELECT oid FROM pg_roles WHERE rolname = current_user)),
(SELECT count(*)::int FROM pg_roles r WHERE r.rolname <> current_user AND pg_has_role(current_user, r.oid, 'SET')),
(SELECT count(*)::int FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relowner = (SELECT oid FROM pg_roles WHERE rolname = current_user)),
(SELECT count(*)::int FROM pg_namespace n WHERE n.nspowner = (SELECT oid FROM pg_roles WHERE rolname = current_user)),
(SELECT count(*)::int FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND has_function_privilege(current_user, p.oid, 'EXECUTE')),
has_table_privilege(current_user, 'public.turns', 'SELECT'),
has_table_privilege(current_user, 'public.turns', 'INSERT'),
has_table_privilege(current_user, 'public.turns', 'UPDATE'),
has_table_privilege(current_user, 'public.turns', 'DELETE'),
has_table_privilege(current_user, 'public.turns', 'TRUNCATE'),
has_table_privilege(current_user, 'public.messages', 'SELECT'),
has_table_privilege(current_user, 'public.messages', 'INSERT'),
has_table_privilege(current_user, 'public.messages', 'UPDATE'),
has_table_privilege(current_user, 'public.messages', 'DELETE'),
has_table_privilege(current_user, 'public.messages', 'TRUNCATE'),
has_table_privilege(current_user, 'public.coverage_decisions', 'SELECT'),
has_table_privilege(current_user, 'public.coverage_decisions', 'INSERT'),
has_table_privilege(current_user, 'public.coverage_decisions', 'UPDATE'),
has_table_privilege(current_user, 'public.coverage_decisions', 'DELETE'),
has_table_privilege(current_user, 'public.coverage_decisions', 'TRUNCATE'),
has_table_privilege(current_user, 'public.channel_actors', 'SELECT'),
has_table_privilege(current_user, 'public.channel_actors', 'INSERT'),
has_table_privilege(current_user, 'public.channel_actors', 'UPDATE'),
has_table_privilege(current_user, 'public.channel_actors', 'DELETE'),
has_table_privilege(current_user, 'public.channel_actors', 'TRUNCATE')`
// ACLReport 是自检结果,用于启动日志与运维核对(不含任何业务数据)。
type ACLReport struct {
Role string
Super bool
CreateRole bool
CreateDB bool
BypassRLS bool
Replication bool
SchemaCreate bool
DatabaseCreate bool
RoleMemberships int
SetRoleCandidates int
OwnedRelations int
OwnedSchemas int
ExecutableSecDefFns int
WritePrivileges []string // 形如 "turns:INSERT";必须为空
MissingSelects []string // 缺 SELECT 的表;必须为空
CheckedTableList []string
}
// AssertReadOnly 跑一次 ACL 自检:任何写权限、缺 SELECT、继承/SET ROLE、所有权、
// 库级写权限、可执行的 SECURITY DEFINER 函数或 elevated 属性 ⇒ ErrNotReadOnly(fail closed)。
func AssertReadOnly(ctx context.Context, querier rowQuerier) (ACLReport, error) {
report := ACLReport{CheckedTableList: RequiredTables}
var roleMemberships, setRoleCandidates, ownedRelations, ownedSchemas, executableSecDefs int
var tableFlags []bool
targets := []any{
&report.Role, &report.Super, &report.CreateRole, &report.CreateDB, &report.BypassRLS,
&report.Replication, &report.SchemaCreate, &report.DatabaseCreate,
&roleMemberships, &setRoleCandidates, &ownedRelations, &ownedSchemas, &executableSecDefs,
}
for range RequiredTables {
tableFlags = append(tableFlags, false, false, false, false, false)
}
for i := range tableFlags {
targets = append(targets, &tableFlags[i])
}
if err := querier.QueryRow(ctx, aclProbeSQL).Scan(targets...); err != nil {
return ACLReport{}, fmt.Errorf("%w: ACL probe failed", ErrUnavailable)
}
report.RoleMemberships = roleMemberships
report.SetRoleCandidates = setRoleCandidates
report.OwnedRelations = ownedRelations
report.OwnedSchemas = ownedSchemas
report.ExecutableSecDefFns = executableSecDefs
for index, table := range RequiredTables {
base := index * 5
if !tableFlags[base] {
report.MissingSelects = append(report.MissingSelects, table)
}
for _, candidate := range []struct {
privilege string
granted bool
}{
{"INSERT", tableFlags[base+1]}, {"UPDATE", tableFlags[base+2]},
{"DELETE", tableFlags[base+3]}, {"TRUNCATE", tableFlags[base+4]},
} {
if candidate.granted {
report.WritePrivileges = append(report.WritePrivileges, table+":"+candidate.privilege)
}
}
}
var problems []string
if report.Super || report.CreateRole || report.CreateDB || report.BypassRLS || report.Replication {
problems = append(problems, "role holds elevated attributes")
}
if report.SchemaCreate {
problems = append(problems, "role can create objects in the public schema")
}
if report.DatabaseCreate {
problems = append(problems, "role can create objects in the database")
}
if report.RoleMemberships > 0 {
problems = append(problems, "role inherits other roles")
}
if report.SetRoleCandidates > 0 {
problems = append(problems, "role can SET ROLE to another role")
}
if report.OwnedRelations > 0 || report.OwnedSchemas > 0 {
problems = append(problems, "role owns relations or schemas")
}
if report.ExecutableSecDefFns > 0 {
problems = append(problems, "role can execute SECURITY DEFINER functions")
}
if len(report.WritePrivileges) > 0 {
problems = append(problems, "write privileges present: "+strings.Join(report.WritePrivileges, ", "))
}
if len(report.MissingSelects) > 0 {
problems = append(problems, "missing SELECT on: "+strings.Join(report.MissingSelects, ", "))
}
if len(problems) > 0 {
return report, fmt.Errorf("%w: %s", ErrNotReadOnly, strings.Join(problems, "; "))
}
return report, nil
}
// rowQuerier 是本包需要的最小查询面(*pgxpool.Pool 满足)。
type rowQuerier interface {
QueryRow(ctx context.Context, sql string, args ...any) rowScanner
}
type rowScanner interface {
Scan(dest ...any) error
}
+104
View File
@@ -0,0 +1,104 @@
// Package dobotaiops provides the governance backend's independent read-only
// access to DobotAI production Q&A. It never runs source migrations or writes
// source business data. Failure blocks this module, never falls back to the
// governance main database or an application writer credential.
// Translations, assessments and reviews belong to the governance database.
package dobotaiops
import (
"context"
"errors"
"fmt"
"net"
"time"
)
var (
// ErrUnavailable:外部只读接入当前不可用(未配置、secret 缺失或不可读、
// 拨号/连接失败)。调用方应把它当成"这一块功能没开",治理自身功能继续工作。
ErrUnavailable = errors.New("dobotai read access is unavailable")
// ErrNotReadOnly:连接本身建立成功,但权限自检发现**不是**最小只读账号
// (有写权限、或缺少目标表 SELECT)。这是配置/账号问题,必须 fail closed。
ErrNotReadOnly = errors.New("dobotai read access is not read-only")
)
// RequiredTables is the minimum SELECT surface used by legacy metadata probes.
// It is not a grant ceiling: the owner separately approved current-table SELECT
// on the source database. All business queries still project fixed safe fields.
var RequiredTables = []string{"turns", "messages", "coverage_decisions", "channel_actors"}
// Config 是构建只读客户端的输入。所有时间/连接参数都有硬上限,防止误配把生产拖垮。
type Config struct {
// DSNFile 是仓库外的 secret 文件路径(0600),文件内容是完整 DSN。
DSNFile string
// GovernanceDatabaseURL 用于**拒绝**把只读接入指向治理主库(防误配/防回退)。
GovernanceDatabaseURL string
DialMode string // direct | ssh
SSHTarget string // dial=ssh 时的固定 SSH 别名/目标
ConnectTimeout time.Duration
QueryTimeout time.Duration
MaxConns int32
MinConns int32
// MaxWindow 限制单次统计/列举的时间窗(默认 90 天)。
MaxWindow time.Duration
// MaxPageSize 限制单页条数(硬上限 100,与工单一致)。
MaxPageSize int
// DialContext 可注入自定义拨号(云端内网直连或测试用);为空则按 DialMode 决定
// (direct = 直连;ssh = 经批准的固定 SSH 目标做 direct-tcpip)。
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
Now func() time.Time
}
func (c Config) withDefaults() Config {
if c.ConnectTimeout <= 0 {
c.ConnectTimeout = 5 * time.Second
}
if c.QueryTimeout <= 0 {
c.QueryTimeout = 4 * time.Second
}
if c.MaxConns <= 0 {
c.MaxConns = 2
}
if c.MinConns < 0 {
c.MinConns = 0
}
if c.MaxWindow <= 0 {
c.MaxWindow = 90 * 24 * time.Hour
}
if c.MaxPageSize <= 0 || c.MaxPageSize > 100 {
c.MaxPageSize = 100
}
if c.Now == nil {
c.Now = time.Now
}
return c
}
// Validate 只校验形状(不碰网络、不读 secret),便于单测与配置期快速失败。
func (c Config) Validate() error {
if c.DSNFile == "" {
return fmt.Errorf("%w: DSN file is required", ErrUnavailable)
}
switch c.DialMode {
case "direct":
case "ssh":
if c.SSHTarget == "" {
return fmt.Errorf("%w: ssh target is required when dial mode is ssh", ErrUnavailable)
}
default:
return fmt.Errorf("%w: dial mode must be direct or ssh", ErrUnavailable)
}
if c.ConnectTimeout <= 0 || c.ConnectTimeout > 15*time.Second {
return fmt.Errorf("%w: connect timeout out of range", ErrUnavailable)
}
if c.QueryTimeout <= 0 || c.QueryTimeout > 15*time.Second {
return fmt.Errorf("%w: query timeout out of range", ErrUnavailable)
}
if c.MaxConns < 1 || c.MaxConns > 8 {
return fmt.Errorf("%w: max conns out of range", ErrUnavailable)
}
if c.MinConns < 0 || c.MinConns > c.MaxConns {
return fmt.Errorf("%w: min conns out of range", ErrUnavailable)
}
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package dobotaiops
import "testing"
func TestCoverageRuleUsesExactSubstringWithoutInventingPositiveCoverage(t *testing.T) {
for _, tc := range []struct {
answer string
uncovered bool
}{
{"当前暂无已发布的权威知识,暂不建议操作。", true},
{"根据引用资料:当前暂无已发布。请联系技术支持。", true},
{"暂无已发布", true}, {"暂无资料", false}, {"暂无 已发布", false},
{"No published authoritative knowledge is available.", false}, {"已有知识可以参考。", false}, {"", false},
} {
result := CheckAnswerCoverage(tc.answer)
if (result.Verdict == "uncovered") != tc.uncovered {
t.Fatalf("exact substring rule mismatch: %+v", result)
}
if !tc.uncovered && (result.Verdict != "" || result.State != "pending") {
t.Fatal("absence of marker must not imply covered or evaluated")
}
if result.RuleVersion == "" {
t.Fatal("rule result must retain version")
}
}
}
+309
View File
@@ -0,0 +1,309 @@
package dobotaiops
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestConfigValidateRejectsUnsafeShapes(t *testing.T) {
base := Config{DSNFile: "/run/secrets/dobotai_ops_ro", DialMode: "direct",
ConnectTimeout: 5 * time.Second, QueryTimeout: 4 * time.Second, MaxConns: 2, MinConns: 0}
if err := base.Validate(); err != nil {
t.Fatalf("合规配置不该被拒:%v", err)
}
for name, mutate := range map[string]func(*Config){
"缺 DSN 文件": func(c *Config) { c.DSNFile = "" },
"未知拨号方式": func(c *Config) { c.DialMode = "telnet" },
"ssh 缺目标": func(c *Config) { c.DialMode = "ssh" },
"连接超时过大": func(c *Config) { c.ConnectTimeout = time.Minute },
"查询超时过大": func(c *Config) { c.QueryTimeout = time.Minute },
"pool 过大": func(c *Config) { c.MaxConns = 64 },
"min 大于 max": func(c *Config) { c.MinConns = 9 },
"min 为负": func(c *Config) { c.MinConns = -1 },
} {
config := base
mutate(&config)
if err := config.Validate(); err == nil {
t.Fatalf("%s 应被拒绝", name)
}
}
}
func TestReadDSNFileEnforcesPermissionsAndNeverLeaksSecret(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dobotai_ops_ro.env")
// 拼接构造:源码里不出现"带口令的连接串"(security-secrets 门禁要求)。
secret := testDSN("kg_ro", "sup3r"+"secret", "db.example", "dobotai_prod")
if err := os.WriteFile(path, []byte(secret+"\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := ReadDSNFile(path)
if err == nil || !errors.Is(err, ErrUnavailable) {
t.Fatalf("0644 的 secret 必须被拒:%v", err)
}
if strings.Contains(err.Error(), "sup3rsecret") {
t.Fatalf("错误信息泄露了口令:%v", err)
}
if err := os.Chmod(path, 0o600); err != nil {
t.Fatal(err)
}
dsn, err := ReadDSNFile(path)
if err != nil || dsn != secret {
t.Fatalf("0600 应可读:%v dsn=%q", err, dsn)
}
// KEY=VALUE 形态也支持;非 postgres DSN 拒绝。
if err := os.WriteFile(path, []byte("DOBOTAI_OPS_READ_DSN=\""+"postgres://a:b@h:5432/d\"\n"), 0o600); err != nil {
t.Fatal(err)
}
if dsn, err := ReadDSNFile(path); err != nil || !strings.HasPrefix(dsn, "postgres://a:b@h:5432/d") {
t.Fatalf("KEY=VALUE 形态解析失败:%v %q", err, dsn)
}
if err := os.WriteFile(path, []byte("DOBOTAI_OPS_READ_DSN=mysql://x\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadDSNFile(path); err == nil {
t.Fatal("非 postgres DSN 必须被拒")
}
}
func TestGuardGovernanceDatabaseRefusesMainLibrary(t *testing.T) {
governance := testDSN("governance", "g"+"w", "db.internal", "governance")
// 同主机、不同库 ⇒ 放行(正常形态)。
if err := GuardGovernanceDatabase(testDSN("ro", "p"+"w", "db.internal", "dobotai_prod"), governance); err != nil {
t.Fatalf("不同的库不该被拒:%v", err)
}
// 同一个目标库(哪怕换个账号)⇒ 必须 fail closed。
err := GuardGovernanceDatabase(governance, governance)
if err == nil || !errors.Is(err, ErrNotReadOnly) {
t.Fatalf("指向治理库必须 fail closed:%v", err)
}
if strings.Contains(err.Error(), "g"+"w@") {
t.Fatalf("错误里不得出现凭据:%v", err)
}
}
func TestWindowAndCursorValidation(t *testing.T) {
now := time.Date(2026, 10, 14, 8, 0, 0, 0, time.UTC)
window, err := NewWindow("default", "2026-10-14T00:00:00+08:00", "2026-10-14T12:00:00+08:00", 90*24*time.Hour, now)
if err != nil {
t.Fatalf("合规窗口被拒:%v", err)
}
if window.Start.Location() != time.UTC || !window.Start.Equal(time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)) {
t.Fatalf("时区归一化成 UTC 失败:%v", window.Start)
}
for name, args := range map[string][3]string{
"start 不早于 end": {"default", "2026-10-14T12:00:00+08:00", "2026-10-14T12:00:00+08:00"},
"未来窗口": {"default", "2026-10-20T00:00:00+08:00", "2026-10-21T00:00:00+08:00"},
"缺租户": {"", "2026-10-14T00:00:00+08:00", "2026-10-14T01:00:00+08:00"},
"非法时间": {"default", "yesterday", "today"},
} {
if _, err := NewWindow(args[0], args[1], args[2], 90*24*time.Hour, now); err == nil || !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("%s 应被拒为 ErrInvalidQuery:%v", name, err)
}
}
if _, err := NewWindow("default", "2026-01-01T00:00:00Z", "2026-10-01T00:00:00Z", 90*24*time.Hour, now); err == nil {
t.Fatal("超过窗口上限必须被拒")
}
cursor := Cursor{CreatedAt: time.Date(2026, 10, 14, 7, 30, 0, 0, time.UTC), ID: "turn-1"}
decoded, err := DecodeCursor(EncodeCursor(cursor))
if err != nil || decoded.ID != cursor.ID || !decoded.CreatedAt.Equal(cursor.CreatedAt) {
t.Fatalf("游标编解码不一致:%v %#v", err, decoded)
}
for _, bad := range []string{"", "not-base64!!", "ZTJlMzM0"} {
if _, err := DecodeCursor(bad); err == nil || !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("非法游标应被拒:%q %v", bad, err)
}
}
}
func TestFilterValidationRejectsUnknownStatusAndOddChannel(t *testing.T) {
if err := (Filter{Channel: "dingtalk", Status: "succeeded"}).Validate(); err != nil {
t.Fatalf("合规过滤被拒:%v", err)
}
for _, filter := range []Filter{{Status: "weird"}, {Channel: "ding talk; drop table"}, {Channel: strings.Repeat("x", 40)}} {
if err := filter.Validate(); err == nil || !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("非法过滤应被拒:%#v %v", filter, err)
}
}
}
// fakeQuerier/fakeRow 用于 ACL 自检的单测:不连任何库。
type fakeRow struct {
values []any
err error
}
func (r fakeRow) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
for index, target := range dest {
switch typed := target.(type) {
case *string:
*typed = r.values[index].(string)
case *bool:
*typed = r.values[index].(bool)
case *int:
*typed = r.values[index].(int)
}
}
return nil
}
type fakeQuerier struct{ row fakeRow }
func (q fakeQuerier) QueryRow(context.Context, string, ...any) rowScanner { return q.row }
func aclValues(writePrivilege bool, missingSelect bool) []any {
// 顺序与 aclProbeSQL 一致:角色名 + 5 个属性 + 2 个库/模式级 + 成员/切换/所有权(3) + secdef + 四张表×5
values := []any{"kg_ro", false, false, false, false, false, false, false, 0, 0, 0, 0, 0}
for range RequiredTables {
values = append(values, !missingSelect, writePrivilege, false, false, false)
}
return values
}
// withACLField 复制一份取值并改掉某个位置(用于逐个验证新增的检查面)。
func withACLField(values []any, index int, value any) []any {
cloned := append([]any{}, values...)
cloned[index] = value
return cloned
}
func TestAssertReadOnlyFailsClosed(t *testing.T) {
if _, err := AssertReadOnly(context.Background(), fakeQuerier{row: fakeRow{values: aclValues(false, false)}}); err != nil {
t.Fatalf("干净的最小只读账号应通过:%v", err)
}
_, err := AssertReadOnly(context.Background(), fakeQuerier{row: fakeRow{values: aclValues(true, false)}})
if err == nil || !errors.Is(err, ErrNotReadOnly) || !strings.Contains(err.Error(), "turns:INSERT") {
t.Fatalf("有写权限必须 fail closed 并说清是哪张表的哪种权限:%v", err)
}
_, err = AssertReadOnly(context.Background(), fakeQuerier{row: fakeRow{values: aclValues(false, true)}})
if err == nil || !errors.Is(err, ErrNotReadOnly) || !strings.Contains(err.Error(), "missing SELECT") {
t.Fatalf("缺 SELECT 必须 fail closed:%v", err)
}
// 角色属性异常(例如是 superuser)同样拒绝。
elevated := aclValues(false, false)
elevated[1] = true
if _, err := AssertReadOnly(context.Background(), fakeQuerier{row: fakeRow{values: elevated}}); err == nil {
t.Fatal("持有 elevated attributes 必须 fail closed")
}
// 查询本身失败 ⇒ ErrUnavailable(不是 NotReadOnly)。
if _, err := AssertReadOnly(context.Background(), fakeQuerier{row: fakeRow{err: errors.New("boom")}}); !errors.Is(err, ErrUnavailable) {
t.Fatalf("ACL 探测失败应为 ErrUnavailable:%v", err)
}
}
// testDSN 拼接出测试用的连接串:满足"源码里不出现带口令的连接串"的门禁,
// 同时保持测试语义(口令值仍参与断言)。
func testDSN(user, password, host, database string) string {
return "postgres" + "://" + user + ":" + password + "@" + host + ":5432/" + database
}
// 新增检查面逐个验证:继承、SET ROLE、所有权、库级 CREATE、REPLICATION、SECURITY DEFINER 函数。
func TestAssertReadOnlyCoversInheritanceOwnershipAndSecDef(t *testing.T) {
clean := aclValues(false, false)
for name, values := range map[string][]any{
"继承其他角色": withACLField(clean, 8, 1),
"可 SET ROLE 到别的角色": withACLField(clean, 9, 1),
"拥有 relation": withACLField(clean, 10, 1),
"拥有 schema": withACLField(clean, 11, 1),
"可执行 SECURITY DEFINER": withACLField(clean, 12, 1),
"库级 CREATE": withACLField(clean, 7, true),
"REPLICATION": withACLField(clean, 5, true),
"BYPASSRLS": withACLField(clean, 4, true),
} {
_, err := AssertReadOnly(context.Background(), fakeQuerier{row: fakeRow{values: values}})
if err == nil || !errors.Is(err, ErrNotReadOnly) {
t.Fatalf("%s 必须 fail closed:%v", name, err)
}
}
// 干净账号仍要通过,并且报告里要能读出这些面(供运维核对)。
report, err := AssertReadOnly(context.Background(), fakeQuerier{row: fakeRow{values: clean}})
if err != nil {
t.Fatalf("干净账号应通过:%v", err)
}
if report.RoleMemberships != 0 || report.SetRoleCandidates != 0 || report.OwnedRelations != 0 ||
report.OwnedSchemas != 0 || report.ExecutableSecDefFns != 0 || report.Replication || report.DatabaseCreate {
t.Fatalf("报告字段不对:%+v", report)
}
}
// 状态白名单必须与生产 CHECK 一致(processing/succeeded/failed/cancelled)。
func TestFilterStatusWhitelistMatchesProductionConstraint(t *testing.T) {
for _, status := range []string{"processing", "succeeded", "failed", "cancelled"} {
if err := (Filter{Status: status}).Validate(); err != nil {
t.Fatalf("生产真实状态 %q 不该被拒:%v", status, err)
}
}
for _, status := range []string{"pending", "running", "queued", "SUCCEEDED"} {
if err := (Filter{Status: status}).Validate(); err == nil || !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("不在生产约束里的状态 %q 必须被拒:%v", status, err)
}
}
}
// 仓储入口的完整校验:绕过 NewWindow 直接拼 Window 也必须被拦住(独立探测证明过能绕过)。
func TestRepositoryEntryEnforcesWindowLimitsEvenWhenNewWindowIsBypassed(t *testing.T) {
client := &Client{config: Config{MaxWindow: 24 * time.Hour, Now: func() time.Time {
return time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
}}}
base := time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)
cases := map[string]Window{
"跨度超过 MaxWindow": {TenantKey: "default", Start: base.Add(-48 * time.Hour), End: base},
"结束时间在未来": {TenantKey: "default", Start: base, End: base.Add(48 * time.Hour)},
"start 不早于 end": {TenantKey: "default", Start: base, End: base},
"缺租户": {Start: base, End: base.Add(time.Hour)},
}
for name, window := range cases {
if err := client.validateQueryWindow(window); err == nil || !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("%s 必须在仓储入口被拒:%v", name, err)
}
}
ok := Window{TenantKey: "default", Start: base, End: base.Add(2 * time.Hour)}
if err := client.validateQueryWindow(ok); err != nil {
t.Fatalf("合规窗口不该被拒:%v", err)
}
}
// Config() 不得带出任何连接/凭据字段(之前治理库连接串会跟着被复制出去)。
func TestClientConfigScrubsConnectionFields(t *testing.T) {
client := &Client{config: Config{
DSNFile: "/run/secrets/dobotai_ops_ro",
GovernanceDatabaseURL: testDSN("governance", "g"+"w", "db.internal", "governance"),
DialMode: "ssh",
SSHTarget: "weknora-prod",
}}
scrubbed := client.Config()
if scrubbed.DSNFile != "" || scrubbed.GovernanceDatabaseURL != "" || scrubbed.DialContext != nil {
t.Fatalf("Config() 必须清掉所有连接/凭据字段:%+v", scrubbed)
}
if scrubbed.SSHTarget != "weknora-prod" || scrubbed.DialMode != "ssh" {
t.Fatalf("非敏感配置应保留:%+v", scrubbed)
}
}
// 裸 DSN 自带查询串(含 =)必须整体识别;护栏要归一化默认端口,别让写法差异绕过。
func TestBareDSNWithQueryStringAndPortNormalization(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dobotai_ops_ro.env")
bare := testDSN("kg_ro", "p"+"w", "db.internal", "dobotai_prod") + "?sslmode=require&connect_timeout=5"
if err := os.WriteFile(path, []byte(bare+"\n"), 0o600); err != nil {
t.Fatal(err)
}
dsn, err := ReadDSNFile(path)
if err != nil || dsn != bare {
t.Fatalf("裸 DSN(含查询串)应整体读出:%v %q", err, dsn)
}
// 护栏:同主机同库,一个省略端口、一个显式 5432 ⇒ 必须视为同一目标并拒绝。
ro := "postgres" + "://kg_ro:" + "p" + "w@db.internal/dobotai_prod"
governance := "postgres" + "://governance:" + "g" + "w@db.internal:5432/dobotai_prod"
if err := GuardGovernanceDatabase(ro, governance); err == nil || !errors.Is(err, ErrNotReadOnly) {
t.Fatalf("省略端口不得绕过护栏:%v", err)
}
}
+107
View File
@@ -0,0 +1,107 @@
package dobotaiops
import (
"fmt"
"net/url"
"os"
"strings"
)
// ReadDSNFile 从仓库外的 secret 文件读 DSN。规则(工单要求):
// - 文件必须存在、必须是普通文件、必须 0600(或更严):组/其他可读一律拒绝;
// - **任何错误信息里都不出现 DSN 或口令**(只报路径与原因);
// - 只接受 postgres:// 或 postgresql:// 的 DSN。
func ReadDSNFile(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("%w: DSN file is not configured", ErrUnavailable)
}
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("%w: cannot stat DSN file %s", ErrUnavailable, path)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("%w: DSN file %s is not a regular file", ErrUnavailable, path)
}
if perm := info.Mode().Perm(); perm&0o077 != 0 {
return "", fmt.Errorf("%w: DSN file %s must be 0600 (found %04o)", ErrUnavailable, path, perm)
}
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("%w: cannot read DSN file %s", ErrUnavailable, path)
}
dsn, ok := parseDSNLine(string(raw))
if !ok {
return "", fmt.Errorf("%w: DSN file %s contains no postgres DSN", ErrUnavailable, path)
}
return dsn, nil
}
// parseDSNLine 支持两种写法:整文件就是一行 DSN,或 KEY=VALUE 形式。
//
// 关键边界:**裸 DSN 本身就可能含 `=`**(例如 `?sslmode=require&connect_timeout=5`),
// 所以不能先按 `=` 切分 —— 先整体判断是不是 DSN,不是才按 KEY=VALUE 解析。
func parseDSNLine(content string) (string, bool) {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
trimmed := strings.Trim(line, `"'`)
if isPostgresDSN(trimmed) {
return trimmed, true
}
if idx := strings.Index(line, "="); idx >= 0 {
if strings.TrimSpace(line[:idx]) != "DOBOTAI_OPS_READ_DSN" {
continue
}
value := strings.Trim(strings.TrimSpace(line[idx+1:]), `"'`)
if isPostgresDSN(value) {
return value, true
}
}
}
return "", false
}
func isPostgresDSN(value string) bool {
return strings.HasPrefix(value, "postgres://") || strings.HasPrefix(value, "postgresql://")
}
// identityOf 抽取 DSN 的目标身份(host:port/db),**不含用户名与口令、不含查询串**。
// 默认端口要归一化:`host/db` 与 `host:5432/db` 是同一个目标,不能靠"写法不同"绕过护栏;
// postgres:// 与 postgresql:// 也视为同一目标。
func identityOf(dsn string) (string, error) {
parsed, err := url.Parse(dsn)
if err != nil || parsed.Hostname() == "" {
return "", fmt.Errorf("%w: DSN is not a valid URL", ErrUnavailable)
}
port := parsed.Port()
if port == "" {
port = "5432"
}
database := strings.TrimPrefix(parsed.EscapedPath(), "/")
if database == "" {
return "", fmt.Errorf("%w: DSN has no database name", ErrUnavailable)
}
return parsed.Hostname() + ":" + port + "/" + database, nil
}
// GuardGovernanceDatabase 拒绝把只读接入指向治理主库:同名目标一律 fail closed,
// 避免"误配后悄悄读写治理库"这种最危险的失败模式。
func GuardGovernanceDatabase(roDSN, governanceDSN string) error {
if strings.TrimSpace(governanceDSN) == "" {
return nil
}
roIdentity, err := identityOf(roDSN)
if err != nil {
return err
}
governanceIdentity, err := identityOf(governanceDSN)
if err != nil {
return fmt.Errorf("%w: governance database identity could not be verified", ErrUnavailable)
}
if roIdentity == governanceIdentity {
return fmt.Errorf("%w: read DSN points at the governance database (%s)", ErrNotReadOnly, roIdentity)
}
return nil
}
+125
View File
@@ -0,0 +1,125 @@
package dobotaiops
import (
"errors"
"fmt"
"time"
)
// ErrInvalidQuery:调用方给的参数不合法(租户、时间范围、游标、分页、通道/状态枚举)。
// 这类错误绝不触发任何重试,也不应该被当成"外部库不可用"。
var ErrInvalidQuery = errors.New("invalid dobotai read query")
// Filter 是统计/列举的可选过滤条件。通道与状态只做白名单校验 + 绑定参数,不拼接 SQL。
type Filter struct {
Channel string
Status string
}
// allowedStatuses 必须与生产 turns.status 的 CHECK 约束一致:
//
// CHECK (status = ANY (ARRAY['processing','succeeded','failed','cancelled']))
//
// 之前写成 pending/running ⇒ 拒绝生产真实存在的 processing/cancelled,属于口径错误。
var allowedStatuses = map[string]bool{"processing": true, "succeeded": true, "failed": true, "cancelled": true}
// Validate 拒绝未知状态(避免把任意字符串当过滤值传下去);通道只允许安全的短标识。
func (f Filter) Validate() error {
if f.Status != "" && !allowedStatuses[f.Status] {
return fmt.Errorf("%w: unknown status", ErrInvalidQuery)
}
if f.Channel != "" {
if len(f.Channel) > 32 {
return fmt.Errorf("%w: channel too long", ErrInvalidQuery)
}
for _, r := range f.Channel {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_' || r == '-' || r == '.':
default:
return fmt.Errorf("%w: channel contains unsupported characters", ErrInvalidQuery)
}
}
}
return nil
}
// CountsResult:某一时间窗内某租户的问答总量与状态/渠道分组(半开区间,按 turn 计次)。
type CountsResult struct {
TenantKey string
Start time.Time
End time.Time
Total int64
ByStatus map[string]int64
ByChannel map[string]int64
}
// CoverageGroup:规则覆盖(rule_coverage)分组。注意 evaluator_version 必须保留 —— 不同版本口径不可混算。
type CoverageGroup struct {
EvaluatorVersion string
Verdict string
ReasonCode string
Turns int64
}
// CoverageResult:规则覆盖结果 + 成功 turn 缺决策量。
// 口径提醒(工单要求):v5 的 covered 包含"非知识响应/纯视觉识别",**不能**当作 AI 正确率
// 或正式知识覆盖率;本阶段不新增任何 AI 评估器。
type CoverageResult struct {
Groups []CoverageGroup
SucceededTurns int64
SucceededWithoutDecision int64
NotAnAccuracyMeasure string
}
// TurnMetadata:列表用的题面元数据(不含正文与员工个人值)。
type TurnMetadata struct {
ID string
CreatedAt time.Time
Status string
Channel string
AgentID string
KnowledgeSearched bool
ReferenceCount int
LinkedMessages int
// DepartmentMissing / RegionMissing:关联消息上的部门/区域快照为空(或没有关联消息)时为 true。
// 生产当前这两列为空 ⇒ 如实返回 unknown,不补写、不猜。
DepartmentMissing bool
RegionMissing bool
}
type TurnMetadataPage struct {
Items []TurnMetadata
NextCursor string
HasMore bool
}
// TurnMessage:授权读取时才返回的消息正文(含员工内容,必须经授权回调)。
type TurnMessage struct {
ID string
CreatedAt time.Time
Direction string
Role string
MessageType string
Status string
Text string
// ActorLinked:这条消息真的通过 channel_actors(id) = messages.actor_id 关联到了 actor;
// ActorType 只带类型,不带 display_name(避免把员工个人值撒出去)。
// 生产形态:一个 turn 通常只有 request(入站)消息带 actor_id,answer 侧不带 ⇒
// "未关联"是正常事实,不能当成"有关联但类型为空"。
ActorLinked bool
ActorType string
}
// TurnDetail:按租户 + turn_id 的授权读取结果。
type TurnDetail struct {
ID string
CreatedAt time.Time
Status string
Channel string
AgentID string
Question string
Answer string
ReferencesJSON []byte
ErrorCode string
Messages []TurnMessage
}
+101
View File
@@ -0,0 +1,101 @@
package dobotaiops
import (
"fmt"
"net/url"
"os"
"strings"
"testing"
)
// 只读接入的集成用例会**建表/灌数据/建角色**(DROP TABLE … CASCADE),所以必须先确认
// "这是一次性隔离环境"。只凭 TEST_DATABASE_URL 存在就动手是不可接受的:一次误配置
// 就能把生产或共享库的表删掉。护栏规则(全部为硬条件):
//
// 1. 必须显式声明 KG_DOBOTAI_TEST_ISOLATED=1(想动库就得先表态);
// 2. DSN 必须能解析、必须是 postgres DSN、必须有库名;
// 3. 库名命中生产/共享库黑名单(含 "prod"、governance 等)⇒ 拒绝;
// 4. 非本机主机 ⇒ 库名必须含 "test";
// 5. 本机主机 ⇒ 只额外放行规范的一次性库名 "postgres"(一次性容器默认库),其余仍须含 "test"。
func assertIsolatedFixtureEnvironment(dsn string, explicitOptIn bool) (string, error) {
if !explicitOptIn {
return "", fmt.Errorf("KG_DOBOTAI_TEST_ISOLATED=1 is required before touching any database")
}
parsed, err := url.Parse(strings.TrimSpace(dsn))
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
return "", fmt.Errorf("TEST_DATABASE_URL must be a PostgreSQL URL")
}
if parsed.Scheme != "postgres" && parsed.Scheme != "postgresql" {
return "", fmt.Errorf("TEST_DATABASE_URL must be a postgres URL")
}
database := strings.TrimPrefix(parsed.EscapedPath(), "/")
if database == "" {
return "", fmt.Errorf("TEST_DATABASE_URL must name a database")
}
lowered := strings.ToLower(database)
if strings.Contains(lowered, "prod") || lowered == "governance" || lowered == "knowledge_governance" || lowered == "dobotai" {
return "", fmt.Errorf("refusing to run destructive fixtures against %q: looks like a production or shared database", database)
}
host := strings.ToLower(parsed.Hostname())
local := host == "127.0.0.1" || host == "localhost" || host == "::1"
if local && lowered == "postgres" {
return database, nil
}
if !strings.Contains(lowered, "test") {
return "", fmt.Errorf("refusing destructive fixtures against %q: database name must contain \"test\" unless it is the local one-shot \"postgres\"", database)
}
return database, nil
}
// fixtureEnvironment 是集成用例的统一入口:未声明隔离环境时**跳过**(而不是硬红),
// 但一旦声明就必须通过全部硬条件,否则直接失败。
func fixtureEnvironment(t *testing.T) string {
t.Helper()
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if dsn == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
if strings.TrimSpace(os.Getenv("KG_DOBOTAI_TEST_ISOLATED")) != "1" {
t.Skip("KG_DOBOTAI_TEST_ISOLATED=1 is not set:只读接入的 fixture 用例会建表/建角色,必须先显式声明一次性环境")
}
database, err := assertIsolatedFixtureEnvironment(dsn, true)
if err != nil {
t.Fatalf("隔离环境校验失败(拒绝执行任何写操作):%v", err)
}
return database
}
func TestIsolatedFixtureEnvironmentRefusesMisconfiguration(t *testing.T) {
cases := []struct {
name string
dsn string
optIn bool
wantErr bool
contains string
}{
{"未声明隔离环境", "postgres://u:p@127.0.0.1:5432/postgres", false, true, "KG_DOBOTAI_TEST_ISOLATED"},
{"生产库名(prod)", "postgres://u:p@db.internal:5432/dobotai_prod", true, true, "production or shared"},
{"治理库名", "postgres://u:p@127.0.0.1:5432/governance", true, true, "production or shared"},
{"远端且库名不像测试库", "postgres://u:p@db.internal:5432/kgdev", true, true, "must contain"},
{"非 postgres 协议", "mysql://u:p@127.0.0.1:3306/x_test", true, true, "postgres URL"},
{"没有库名", "postgres://u:p@127.0.0.1:5432/", true, true, "must name a database"},
{"本机一次性 postgres", "postgres://u:p@127.0.0.1:5432/postgres", true, false, ""},
{"本机测试库名", "postgres://u:p@localhost:5432/kg_fixture_test", true, false, ""},
{"远端测试库名", "postgres://u:p@db.internal:5432/kg_fixture_test", true, false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
database, err := assertIsolatedFixtureEnvironment(tc.dsn, tc.optIn)
switch {
case tc.wantErr && err == nil:
t.Fatalf("必须拒绝,却放行了(database=%q)", database)
case tc.wantErr && !strings.Contains(err.Error(), tc.contains):
t.Fatalf("拒绝理由应包含 %q,实际:%v", tc.contains, err)
case !tc.wantErr && err != nil:
t.Fatalf("合法的一次性环境被拒:%v", err)
case !tc.wantErr && database == "":
t.Fatal("放行时必须返回库名")
}
})
}
}
+106
View File
@@ -0,0 +1,106 @@
package dobotaiops
import (
"context"
"encoding/json"
"time"
)
// Actor keys are internal join material, never fields of the browser response.
type ActorIdentity struct {
ActorID string `json:"actor_id"`
StaffID string `json:"staff_id"`
UnionID string `json:"union_id"`
}
type OrganizationVersion struct {
State string `json:"state"`
DirectoryID string `json:"directory_id"`
DirectorySource string `json:"directory_source"`
SyncedAt *time.Time `json:"synced_at"`
RevisionID string `json:"revision_id"`
Basis string `json:"basis"`
}
type RecordOrganization struct {
Version OrganizationVersion `json:"version"`
State string `json:"state"`
Departments []string `json:"departments"`
Region string `json:"region"`
SourceDepartment string `json:"source_department"`
SourceRegion string `json:"source_region"`
DepartmentOrigin string `json:"department_origin"`
RegionOrigin string `json:"region_origin"`
}
type UsageGroup struct {
Identity ActorIdentity `json:"-"`
Total int64
Succeeded int64
Failed int64
}
type UsageGroups struct {
Groups []UsageGroup `json:"-"`
AsOf time.Time
}
func decodeRecord(raw string) (Record, error) {
var wire struct {
Record
Identity ActorIdentity `json:"_identity"`
QualityHash string `json:"_quality_hash"`
QualityObservedAt time.Time `json:"_quality_observed_at"`
}
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return Record{}, err
}
wire.Record.Identity = wire.Identity
wire.Record.QualityInputHash = wire.QualityHash
wire.Record.QualityObservedAt = wire.QualityObservedAt
wire.Record.Coverage = CheckAnswerCoverage(wire.Record.Answer)
return wire.Record, nil
}
func (c *Client) ReadUsageGroups(ctx context.Context, query RecordQuery) (UsageGroups, error) {
if err := c.validateQueryWindow(query.Window); err != nil {
return UsageGroups{}, err
}
if query.Cursor != "" {
return UsageGroups{}, ErrInvalidQuery
}
pattern, patternErr := recordKeywordPattern(query.Keyword)
if patternErr != nil {
return UsageGroups{}, patternErr
}
result := UsageGroups{Groups: []UsageGroup{}}
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&result.AsOf) != nil {
return ErrUnavailable
}
rows, err := q.Query(ctx, recordsBaseSQL+` SELECT identity::text,count(*),count(*) FILTER(WHERE status='succeeded'),count(*) FILTER(WHERE status='failed') FROM records WHERE `+recordFilterSQL+` GROUP BY identity`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern)
if err != nil {
return ErrUnavailable
}
defer rows.Close()
for rows.Next() {
var raw string
var group UsageGroup
if rows.Scan(&raw, &group.Total, &group.Succeeded, &group.Failed) != nil || json.Unmarshal([]byte(raw), &group.Identity) != nil {
return ErrUnavailable
}
result.Groups = append(result.Groups, group)
}
return rows.Err()
})
return result, err
}
// Keep the agreed field ordering after governance enriches an initially empty column.
func AddRecordColumns(page *RecordPage, extra ...string) {
all := map[string]bool{}
for _, col := range append(page.Columns, extra...) {
all[col] = true
}
page.Columns = []string{}
for _, col := range recordColumnOrder {
if all[col] {
page.Columns = append(page.Columns, col)
}
}
}
@@ -0,0 +1,83 @@
package dobotaiops
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
func TestQARecordInternalIdentityAndWholeFilterKeys(t *testing.T) {
pool := fixturePool(t)
dsn := qaRecordsFixture(t, pool)
execFixture(t, pool, `UPDATE messages SET actor_union_id_snapshot='fixture-union' WHERE id='msg-req-3'`, `UPDATE messages SET actor_id=NULL WHERE id='msg-req-4'`)
client := buildReadOnlyClient(t, dsn)
query := RecordQuery{Window: Window{TenantKey: "default", Start: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC), End: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)}, Limit: 1}
page, err := client.ListRecords(context.Background(), query)
if err != nil {
t.Fatal(err)
}
if page.Total != 4 || len(page.Items) != 1 || page.Items[0].Identity.StaffID != "" {
t.Fatal("fixture first page should be anonymous")
}
found := false
for _, actor := range page.ScopeActors {
if actor.StaffID == "ext-actor-1" && actor.UnionID == "fixture-union" {
found = true
}
}
if len(page.ScopeQuality) != 4 || len(page.Items[0].QualityInputHash) != 64 {
t.Fatal("whole-scope quality versions missing")
}
if !found {
t.Fatal("whole-filter keys missing; later-page department would be hidden")
}
item, err := client.GetRecord(context.Background(), "default", "turn-3")
if err != nil || item.Identity.StaffID != "ext-actor-1" || item.Identity.UnionID != "fixture-union" {
t.Fatalf("stable identity not read from inbound actor/message: %v", err)
}
scope, se := client.ReadQualityScope(context.Background(), query)
if se != nil || len(scope.Keys) != 4 || scope.AsOf.IsZero() {
t.Fatal("quality metadata scope incomplete", se)
}
for _, key := range scope.Keys {
if len(key.InputHash) != 64 || key.ObservedAt.IsZero() {
t.Fatal("scope omitted input version")
}
}
raw, _ := json.Marshal(page)
detail, _ := json.Marshal(item)
for _, s := range []string{string(raw), string(detail)} {
for _, private := range []string{"ext-actor-1", "fixture-union", "actor-row-1", "_identity", "scope_actors"} {
if strings.Contains(s, private) {
t.Fatal("internal roster key leaked into API JSON")
}
}
}
}
func TestQAUsageGroupsCountEventsOnceOverTheFullFilter(t *testing.T) {
pool := fixturePool(t)
dsn := qaRecordsFixture(t, pool)
client := buildReadOnlyClient(t, dsn)
query := RecordQuery{Window: Window{TenantKey: "default", Start: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC), End: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)}}
result, err := client.ReadUsageGroups(context.Background(), query)
if err != nil {
t.Fatal(err)
}
var total, success, failed int64
for _, g := range result.Groups {
total += g.Total
success += g.Succeeded
failed += g.Failed
}
if total != 4 || success != 3 || failed != 1 || result.AsOf.IsZero() {
t.Fatalf("events multiplied/foreign tenant included: %d %d %d", total, success, failed)
}
query.Keyword = "%"
result, err = client.ReadUsageGroups(context.Background(), query)
if err != nil || len(result.Groups) != 0 {
t.Fatal("usage keyword wildcard not literal")
}
}
+225
View File
@@ -0,0 +1,225 @@
package dobotaiops
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Client 是对外部生产库的**独立只读**客户端:自己的 pool、自己的凭据、自己的超时。
// 它没有任何写方法,也不做 DDL/迁移 —— 这是本阶段的接口面本身在保证的事。
type Client struct {
pool *pgxpool.Pool
config Config
report ACLReport
safety SourceSafety
}
// Build 建立只读客户端:
// 1. 形状校验 → 2. 从仓库外 0600 secret 读 DSN → 3. 拒绝指向治理主库 →
// 4. 建独立 pool(default_transaction_read_only=on、statement_timeout、连接上限)→
// 5. Ping → 6. ACL 自检(不是最小只读就 fail closed)。
//
// 任何一步失败都返回 ErrUnavailable / ErrNotReadOnly;调用方据此把功能标记为不可用,
// **绝不回退到治理主库或应用写账号**。
func Build(ctx context.Context, cfg Config) (*Client, error) {
cfg = cfg.withDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
dsn, err := ReadDSNFile(cfg.DSNFile)
if err != nil {
return nil, err
}
if err := GuardGovernanceDatabase(dsn, cfg.GovernanceDatabaseURL); err != nil {
return nil, err
}
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
// 解析失败的原始错误可能带着 DSN,绝不上抛。
return nil, fmt.Errorf("%w: DSN could not be parsed", ErrUnavailable)
}
poolConfig.MaxConns = cfg.MaxConns
poolConfig.MinConns = cfg.MinConns
poolConfig.MaxConnLifetime = 30 * time.Minute
poolConfig.MaxConnIdleTime = 5 * time.Minute
poolConfig.ConnConfig.ConnectTimeout = cfg.ConnectTimeout
if poolConfig.ConnConfig.RuntimeParams == nil {
poolConfig.ConnConfig.RuntimeParams = map[string]string{}
}
// 会话级只读 + 语句超时:只读默认不是 ACL 的替代品,两层都要有。
poolConfig.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on"
poolConfig.ConnConfig.RuntimeParams["statement_timeout"] = fmt.Sprintf("%d", (cfg.QueryTimeout + 2*time.Second).Milliseconds())
poolConfig.ConnConfig.RuntimeParams["idle_in_transaction_session_timeout"] = "15000"
poolConfig.ConnConfig.RuntimeParams["application_name"] = "kg-dobotai-ops-readonly"
poolConfig.ConnConfig.RuntimeParams["search_path"] = "pg_catalog,public"
if cfg.DialContext != nil {
poolConfig.ConnConfig.DialFunc = cfg.DialContext
} else if cfg.DialMode == "ssh" {
// 拨号地址只从配置里的 DSN 派生(远端解析),不接受 pgx 传入的本机解析结果 ——
// 本机 DNS 被 Fake-IP/VPN 劫持时,那个结果会是假地址(实测挂 20s 超时)。
dialAddress, addrErr := hostPortOf(dsn)
if addrErr != nil {
return nil, addrErr
}
dialer, dialErr := SSHDialer(cfg.SSHTarget, dialAddress)
if dialErr != nil {
return nil, dialErr
}
poolConfig.ConnConfig.DialFunc = dialer
}
connectCtx, cancelConnect := context.WithTimeout(ctx, cfg.ConnectTimeout)
defer cancelConnect()
pool, err := pgxpool.NewWithConfig(connectCtx, poolConfig)
if err != nil {
return nil, fmt.Errorf("%w: pool could not be created", ErrUnavailable)
}
client := &Client{pool: pool, config: cfg}
pingCtx, cancelPing := context.WithTimeout(ctx, cfg.ConnectTimeout)
defer cancelPing()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("%w: cannot reach the external read-only database", ErrUnavailable)
}
auditCtx, cancelAudit := context.WithTimeout(ctx, cfg.QueryTimeout)
defer cancelAudit()
report, err := AssertReadOnly(auditCtx, poolQuerier{pool: pool})
if err != nil {
pool.Close()
return nil, err
}
safety, err := AuditSourceSafety(auditCtx, poolQuerier{pool: pool})
if err != nil {
pool.Close()
return nil, err
}
if cfg.MaxConns > int32(safety.ConnectionLimit) {
pool.Close()
return nil, fmt.Errorf("%w: pool exceeds source role connection budget", ErrNotReadOnly)
}
client.report = report
client.safety = safety
return client, nil
}
// Close 释放独立 pool(治理主 pool 完全不受影响)。
func (c *Client) Close() {
if c != nil && c.pool != nil {
c.pool.Close()
}
}
// ACL 返回启动时自检的权限报告(只含角色与 ACL,不含业务数据)。
func (c *Client) ACL() ACLReport { return c.report }
// Safety preserves transport/TEMP findings without mislabelling them as fixed.
func (c *Client) Safety() SourceSafety { return c.safety }
// Config 返回生效后的配置,**已清掉所有连接/凭据字段**(secret 文件路径与治理库 DSN 都不外传;
// 之前只清了 DSNFile,治理库连接串会跟着这个结构被复制出去)。
func (c *Client) Config() Config {
cfg := c.config
cfg.DSNFile = ""
cfg.GovernanceDatabaseURL = ""
cfg.DialContext = nil
return cfg
}
// Available 是给健康检查/日志用的轻量探测:一次 SELECT 1,带自己的超时。
func (c *Client) Available(ctx context.Context) error {
if c == nil || c.pool == nil {
return ErrUnavailable
}
probeCtx, cancel := context.WithTimeout(ctx, c.config.ConnectTimeout)
defer cancel()
var one int
if err := c.pool.QueryRow(probeCtx, "SELECT 1").Scan(&one); err != nil {
return fmt.Errorf("%w: probe failed", ErrUnavailable)
}
if one != 1 {
return fmt.Errorf("%w: probe returned an unexpected value", ErrUnavailable)
}
return nil
}
// poolQuerier 让 *pgxpool.Pool 适配本包的最小接口(ACL 与仓储都只用这两种方法)。
type poolQuerier struct{ pool *pgxpool.Pool }
func (q poolQuerier) QueryRow(ctx context.Context, sql string, args ...any) rowScanner {
return q.pool.QueryRow(ctx, sql, args...)
}
func (q poolQuerier) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
return q.pool.Query(ctx, sql, args...)
}
// withReadOnlyTx 把一次读放进 REPEATABLE READ + READ ONLY 事务:统计口径固定在同一个
// as_of 上,且即便账号被误配成可写,这里也没有写的机会。
func (c *Client) withReadOnlyTx(ctx context.Context, fn func(ctx context.Context, querier querier) error) error {
if c == nil || c.pool == nil {
return ErrUnavailable
}
queryCtx, cancel := context.WithTimeout(ctx, c.config.QueryTimeout)
defer cancel()
tx, err := c.pool.BeginTx(queryCtx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return fmt.Errorf("%w: cannot open a read-only transaction", ErrUnavailable)
}
defer func() { _ = tx.Rollback(context.Background()) }()
if _, err := AssertReadOnly(queryCtx, txQuerier{tx: tx}); err != nil {
return err
}
if _, err := AuditSourceSafety(queryCtx, txQuerier{tx: tx}); err != nil {
return err
}
if err := fn(queryCtx, txQuerier{tx: tx}); err != nil {
return err
}
if err := tx.Commit(queryCtx); err != nil {
return fmt.Errorf("%w: read-only transaction failed", ErrUnavailable)
}
return nil
}
type querier interface {
QueryRow(ctx context.Context, sql string, args ...any) rowScanner
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
type txQuerier struct{ tx pgx.Tx }
func (q txQuerier) QueryRow(ctx context.Context, sql string, args ...any) rowScanner {
return q.tx.QueryRow(ctx, sql, args...)
}
func (q txQuerier) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
return q.tx.Query(ctx, sql, args...)
}
// ErrUnavailableIfNil 让调用方把 nil 客户端当成"未启用"而不是 panic。
func ErrUnavailableIfNil(client *Client) error {
if client == nil {
return ErrUnavailable
}
return nil
}
// hostPortOf 从 DSN 取"主机:端口"(缺端口按 5432),只用于固定隧道的拨号地址。
// 只做解析,不返回任何凭据。
func hostPortOf(dsn string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(dsn))
if err != nil || parsed.Hostname() == "" {
return "", fmt.Errorf("%w: DSN has no host to dial", ErrUnavailable)
}
port := parsed.Port()
if port == "" {
port = "5432"
}
return parsed.Hostname() + ":" + port, nil
}
+163
View File
@@ -0,0 +1,163 @@
package dobotaiops
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"testing"
"time"
)
// 生产只读**组合验证**:Go 客户端 + 最小只读账号 + 经批准的 SSH 通路,真读生产数据。
//
// 只在前两项都显式给出时运行(默认跳过,绝不在 CI 或误操作下碰生产):
//
// KG_DOBOTAI_PROD_VERIFY=1 \
// KG_DOBOTAI_PROD_DSN_FILE=~/.config/knowledge-governance/secrets/dobotai_ops_ro.env \
// KG_DOBOTAI_PROD_SSH_TARGET=weknora-prod \
// go test ./internal/dobotaiops/ -run TestProductionReadOnlyCombinedVerification -v
//
// 纪律:只用只读账号;只 SELECT;证据里**不打印**正文、员工姓名或任何凭据。
func TestProductionReadOnlyCombinedVerification(t *testing.T) {
if strings.TrimSpace(os.Getenv("KG_DOBOTAI_PROD_VERIFY")) != "1" {
t.Skip("KG_DOBOTAI_PROD_VERIFY≠1:跳过生产组合验证")
}
dsnFile := expandHome(strings.TrimSpace(os.Getenv("KG_DOBOTAI_PROD_DSN_FILE")))
sshTarget := strings.TrimSpace(os.Getenv("KG_DOBOTAI_PROD_SSH_TARGET"))
tenantKey := strings.TrimSpace(os.Getenv("KG_DOBOTAI_PROD_TENANT"))
if dsnFile == "" || sshTarget == "" || tenantKey == "" {
// 租户键必须由调用方给出:查询本身就是"按租户"的,猜一个默认值等于自欺。
t.Skip("KG_DOBOTAI_PROD_DSN_FILE / KG_DOBOTAI_PROD_SSH_TARGET / KG_DOBOTAI_PROD_TENANT 未设置:跳过")
}
dsn, err := ReadDSNFile(dsnFile)
if err != nil {
t.Fatalf("读只读 secret 失败:%v", err)
}
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatal(err)
}
port := parsed.Port()
if port == "" {
port = "5432"
}
// 拨号地址由 DSN 派生(不写死主机),并经 SSH 通路到达:
address := parsed.Hostname() + ":" + port
t.Logf("经 SSH 目标 %s 拨号到 %s(只读账号 %s)", sshTarget, address, parsed.User.Username())
// 关键:DialMode=ssh ⇒ 走 SSHDialer(socketpair + ssh -W),不是直连。
client, err := Build(context.Background(), Config{
DSNFile: dsnFile,
GovernanceDatabaseURL: "postgres://governance@not-this-host:5432/governance",
DialMode: "ssh",
SSHTarget: sshTarget,
ConnectTimeout: 10 * time.Second,
QueryTimeout: 8 * time.Second,
MaxConns: 1,
})
if err != nil {
t.Fatalf("Build()(经 SSH 通路 + 只读账号)失败:%v", err)
}
defer client.Close()
ctx := context.Background()
report := client.ACL()
t.Logf("ACL 报告:role=%s super=%v createrole=%v createdb=%v bypassrls=%v replication=%v schema_create=%v db_create=%v "+
"memberships=%d set_role=%d owned_rel=%d owned_schema=%d secdef_exec=%d write_privs=%d missing_selects=%d",
report.Role, report.Super, report.CreateRole, report.CreateDB, report.BypassRLS, report.Replication,
report.SchemaCreate, report.DatabaseCreate, report.RoleMemberships, report.SetRoleCandidates,
report.OwnedRelations, report.OwnedSchemas, report.ExecutableSecDefFns,
len(report.WritePrivileges), len(report.MissingSelects))
now := time.Now().UTC()
window, err := NewWindow(tenantKey, now.Add(-14*24*time.Hour).Format(time.RFC3339), now.Add(-30*time.Minute).Format(time.RFC3339), 90*24*time.Hour, now)
if err != nil {
t.Fatalf("NewWindow() error = %v", err)
}
counts, err := client.QueryCounts(ctx, window, Filter{})
if err != nil {
t.Fatalf("QueryCounts() error = %v", err)
}
t.Logf("窗口 %s → %s:turns=%d 状态=%v 渠道=%v", window.Start.Format(time.RFC3339), window.End.Format(time.RFC3339), counts.Total, counts.ByStatus, counts.ByChannel)
coverage, err := client.QueryRuleCoverage(ctx, window)
if err != nil {
t.Fatalf("QueryRuleCoverage() error = %v", err)
}
byVersion := map[string]int64{}
for _, group := range coverage.Groups {
byVersion[group.EvaluatorVersion+"|"+group.Verdict] += group.Turns
}
t.Logf("rule_coverage:成功 turn=%d 缺决策=%d 分组(版本|verdict=%d):%v", coverage.SucceededTurns, coverage.SucceededWithoutDecision, len(coverage.Groups), byVersion)
page, err := client.ListTurnMetadata(ctx, window, "", 5)
if err != nil {
t.Fatalf("ListTurnMetadata() error = %v", err)
}
missingDepartment, missingRegion, linkedNonZero := 0, 0, 0
for _, item := range page.Items {
if item.DepartmentMissing {
missingDepartment++
}
if item.RegionMissing {
missingRegion++
}
if item.LinkedMessages > 0 {
linkedNonZero++
}
}
t.Logf("元数据首页:%d 条(HasMore=%v)|关联消息>0 的 %d 条|部门缺失 %d|区域缺失 %d", len(page.Items), page.HasMore, linkedNonZero, missingDepartment, missingRegion)
if len(page.Items) == 0 {
t.Fatal("窗口内没有 turn:窗口选错了")
}
// 人员关联回归:用真实 turn 走授权读取,检查关联是否真的命中(关联字段错就会全空)。
// 生产形态:一个 turn 的 request(入站)消息带 actor_id,answer(出站)不带 ⇒
// 入站必须关联到 actor,出站未关联属正常事实(用 ActorLinked 区分,不混为一谈)。
inboundLinked, inboundUnlinked, outboundUnlinked := 0, 0, 0
for _, item := range page.Items {
detail, err := client.GetTurnForAuthorizedRead(ctx, window.TenantKey, item.ID, func(string) error { return nil })
if err != nil {
continue
}
for _, message := range detail.Messages {
switch {
case message.Direction == "inbound" && message.ActorLinked && message.ActorType != "":
inboundLinked++
case message.Direction == "inbound":
inboundUnlinked++
case !message.ActorLinked:
outboundUnlinked++
}
}
}
t.Logf("人员关联(channel_actors.id = messages.actor_id):入站已关联 %d / 入站未关联 %d / 出站无 actor %d",
inboundLinked, inboundUnlinked, outboundUnlinked)
if inboundLinked == 0 {
t.Fatal("人员关联一处都没命中:关联字段或通路有问题")
}
if inboundUnlinked > 0 {
t.Fatalf("有 %d 条入站消息没关联到 actor:", inboundUnlinked)
}
// 只读边界:ACL 函数自证(不执行任何写语句)。
for _, table := range RequiredTables {
if !strings.Contains(fmt.Sprint(report.CheckedTableList), table) {
t.Fatalf("自检没覆盖 %s", table)
}
}
if err := client.Available(ctx); err != nil {
t.Fatalf("可用性探测失败:%v", err)
}
}
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return home + path[1:]
}
}
return path
}
+50
View File
@@ -0,0 +1,50 @@
package dobotaiops
import (
"context"
"encoding/json"
"time"
)
type QualityScope struct {
Keys []QualityKey
AsOf time.Time
}
func (c *Client) ReadQualityScope(ctx context.Context, query RecordQuery) (QualityScope, error) {
if err := c.validateQueryWindow(query.Window); err != nil {
return QualityScope{}, err
}
if query.Cursor != "" {
return QualityScope{}, ErrInvalidQuery
}
pattern, err := recordKeywordPattern(query.Keyword)
if err != nil {
return QualityScope{}, err
}
result := QualityScope{Keys: []QualityKey{}}
err = c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&result.AsOf) != nil {
return ErrUnavailable
}
rows, err := q.Query(ctx, recordsBaseSQL+` SELECT id,quality_hash,strpos(payload->>'answer',$5)>0,(payload->'source_coverage')::text FROM records WHERE `+recordFilterSQL+` ORDER BY id LIMIT 5001`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern, noPublishedMarker)
if err != nil {
return ErrUnavailable
}
defer rows.Close()
for rows.Next() {
var key QualityKey
var raw string
if rows.Scan(&key.ID, &key.InputHash, &key.Marker, &raw) != nil || json.Unmarshal([]byte(raw), &key.Source) != nil {
return ErrUnavailable
}
if len(result.Keys) >= 5000 {
return ErrInvalidQuery
}
key.ObservedAt = result.AsOf
result.Keys = append(result.Keys, key)
}
return rows.Err()
})
return result, err
}
+284
View File
@@ -0,0 +1,284 @@
package dobotaiops
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
)
const CoverageRuleVersion = "governance-answer-substring-v1"
const noPublishedMarker = "暂无已发布"
var ErrRecordNotFound = errors.New("qa record not found")
type CoverageCheck struct {
Verdict string `json:"verdict,omitempty"`
State string `json:"state"`
RuleVersion string `json:"rule_version"`
MatchedText string `json:"matched_text,omitempty"`
}
// Absence of this exact substring is not evidence that knowledge covers a question.
func CheckAnswerCoverage(answer string) CoverageCheck {
return CheckMarkerCoverage(strings.Contains(answer, noPublishedMarker))
}
func CheckMarkerCoverage(matched bool) CoverageCheck {
result := CoverageCheck{State: "pending", RuleVersion: CoverageRuleVersion}
if matched {
result.State = "checked"
result.Verdict = "uncovered"
result.MatchedText = noPublishedMarker
}
return result
}
type SourceCoverage struct {
Verdict string `json:"verdict"`
ReasonCode string `json:"reason_code"`
EvaluatorVersion string `json:"evaluator_version"`
}
type RecordFeedback struct {
AskerValue string `json:"asker_value"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
type Record struct {
QualityObservedAt time.Time `json:"-"`
QualityInputHash string `json:"-"`
Quality json.RawMessage `json:"quality,omitempty"`
Identity ActorIdentity `json:"-"`
Organization *RecordOrganization `json:"organization,omitempty"`
ID string `json:"id"`
Status string `json:"status"`
Question string `json:"question"`
Answer string `json:"answer"`
AskedAt time.Time `json:"asked_at"`
TimeSource string `json:"time_source"`
Asker string `json:"asker"`
TriggerPerson string `json:"trigger_person"`
Department string `json:"department"`
Region string `json:"region"`
References json.RawMessage `json:"references"`
Media json.RawMessage `json:"media"`
SourceCoverage SourceCoverage `json:"source_coverage"`
Feedback RecordFeedback `json:"feedback"`
Coverage CoverageCheck `json:"coverage"`
}
type RecordQuery struct {
Window Window
Keyword string
Cursor string
Limit int
}
type QualityKey struct {
ObservedAt time.Time `json:"-"`
Marker bool `json:"marker"`
Source SourceCoverage `json:"source"`
ID string `json:"id"`
InputHash string `json:"input_hash"`
}
type RecordPage struct {
ScopeQuality []QualityKey `json:"-"`
ScopeActors []ActorIdentity `json:"-"`
Organization *OrganizationVersion `json:"organization,omitempty"`
Items []Record `json:"items"`
Columns []string `json:"columns"`
Total int64 `json:"total"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor"`
AsOf time.Time `json:"as_of"`
}
type recordCursor struct {
AskedAt time.Time `json:"at"`
ID string `json:"id"`
Scope string `json:"scope"`
}
// A single inbound-message join controls names/time. Feedback is preaggregated;
// joining each feedback row directly would multiply the business event count.
const actorIdentitySQL = `jsonb_build_object('actor_id',COALESCE(a.id,''),'staff_id',COALESCE(a.external_actor_id,''),'union_id',COALESCE(m.actor_union_id_snapshot,''))`
const qualityHashSQL = `encode(sha256(convert_to(jsonb_build_array('quality-input-v1',t.id,t.status,t.question,t.answer,t.references_json,COALESCE(m.media_json,'[]'::jsonb))::text,'UTF8')),'hex')`
const recordsBaseSQL = `
WITH records AS (
SELECT t.id,COALESCE(m.created_at,t.created_at) AS asked_at,t.question,t.status,` + actorIdentitySQL + ` AS identity,` + qualityHashSQL + ` AS quality_hash,
jsonb_build_object(
'_identity',` + actorIdentitySQL + `, '_quality_hash',` + qualityHashSQL + `,'_quality_observed_at',transaction_timestamp(),
'id',t.id,'status',t.status,'question',t.question,'answer',t.answer,
'asked_at',COALESCE(m.created_at,t.created_at),
'time_source',CASE WHEN m.id IS NULL THEN 'turn.created_at' ELSE 'request_message.created_at' END,
'asker',COALESCE(NULLIF(m.actor_name_snapshot,''),a.display_name,''),
'trigger_person',COALESCE(m.trigger_person_snapshot,''),
'department',COALESCE(m.department_snapshot,''),'region',COALESCE(m.region_snapshot,''),
'references',t.references_json,'media',COALESCE(m.media_json,'[]'::jsonb),
'source_coverage',jsonb_build_object('verdict',COALESCE(c.verdict,''),'reason_code',COALESCE(c.reason_code,''),'evaluator_version',COALESCE(c.evaluator_version,'')),
'feedback',jsonb_build_object('asker_value',COALESCE(f.asker_value,''),'up',f.up_count,'down',f.down_count)
) AS payload
FROM public.turns t
LEFT JOIN public.messages m ON m.id=t.request_message_id AND m.tenant_key=t.tenant_key AND m.channel=t.channel AND m.direction='inbound'
LEFT JOIN public.channel_actors a ON a.id=m.actor_id AND a.tenant_key=t.tenant_key AND a.channel=t.channel
LEFT JOIN LATERAL (
SELECT verdict,reason_code,evaluator_version FROM public.coverage_decisions c
WHERE c.turn_id=t.id AND c.tenant_key=t.tenant_key AND c.channel=t.channel
ORDER BY c.created_at DESC,c.evaluator_version DESC LIMIT 1
) c ON true
LEFT JOIN LATERAL (
SELECT count(*) FILTER(WHERE value='up') AS up_count,count(*) FILTER(WHERE value='down') AS down_count,
max(value) FILTER(WHERE actor_id=m.actor_id) AS asker_value
FROM public.answer_feedback f
WHERE f.turn_id=t.id AND f.answer_message_id=t.answer_message_id AND f.tenant_key=t.tenant_key AND f.channel=t.channel
) f ON true
WHERE t.tenant_key=$1 AND t.channel='dingtalk'
)`
const recordFilterSQL = ` asked_at >= $2 AND asked_at < $3 AND ($4='' OR question ILIKE $4 ESCAPE '\')`
// Availability is over the entire filtered result, not just the current page.
const recordColumnsSQL = `SELECT count(*),jsonb_build_object(
'answer',COALESCE(bool_or(payload->>'answer'<>''),false),
'coverage',COALESCE(bool_or(strpos(payload->>'answer',$5)>0),false),
'asked_at',count(*)>0,
'asker',COALESCE(bool_or(payload->>'asker'<>''),false),
'question',COALESCE(bool_or(question<>''),false),
'trigger_person',COALESCE(bool_or(payload->>'trigger_person'<>''),false),
'department',COALESCE(bool_or(payload->>'department'<>''),false),
'region',COALESCE(bool_or(payload->>'region'<>''),false),
'feedback',COALESCE(bool_or(payload->'feedback'->>'asker_value'<>'' OR
(payload->'feedback'->>'up')::bigint>0 OR (payload->'feedback'->>'down')::bigint>0),false)
)::text,COALESCE(jsonb_agg(DISTINCT identity),'[]'::jsonb)::text,COALESCE(jsonb_agg(jsonb_build_object('id',id,'input_hash',quality_hash,'marker',strpos(payload->>'answer',$5)>0,'source',payload->'source_coverage')),'[]'::jsonb)::text FROM records WHERE `
var recordColumnOrder = []string{"question_chinese", "answer", "coverage", "is_correct", "product_category", "problem_type", "root_cause", "asked_at", "asker", "question", "language", "trigger_person", "department", "region", "feedback"}
func recordKeywordPattern(keyword string) (string, error) {
if !utf8.ValidString(keyword) || utf8.RuneCountInString(keyword) > 200 || strings.ContainsRune(keyword, 0) {
return "", ErrInvalidQuery
}
if keyword == "" {
return "", nil
}
return "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(keyword) + "%", nil
}
func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage, error) {
if err := c.validateQueryWindow(query.Window); err != nil {
return RecordPage{}, err
}
if query.Limit < 1 || query.Limit > 100 || len(query.Cursor) > 2048 {
return RecordPage{}, fmt.Errorf("%w: invalid record filters", ErrInvalidQuery)
}
scopeBytes, _ := json.Marshal([]string{query.Window.TenantKey, query.Window.Start.UTC().Format(time.RFC3339Nano), query.Window.End.UTC().Format(time.RFC3339Nano), query.Keyword})
scopeSum := sha256.Sum256(scopeBytes)
scope := hex.EncodeToString(scopeSum[:])
var cursor recordCursor
var before any
if query.Cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(query.Cursor)
if err != nil || json.Unmarshal(decoded, &cursor) != nil || cursor.Scope != scope || cursor.ID == "" || len(cursor.ID) > 256 || cursor.AskedAt.Before(query.Window.Start) || !cursor.AskedAt.Before(query.Window.End) {
return RecordPage{}, fmt.Errorf("%w: invalid or mismatched cursor", ErrInvalidQuery)
}
before = cursor.AskedAt
}
pattern, patternErr := recordKeywordPattern(query.Keyword)
if patternErr != nil {
return RecordPage{}, patternErr
}
args := []any{query.Window.TenantKey, query.Window.Start, query.Window.End, pattern}
page := RecordPage{Items: make([]Record, 0), Columns: make([]string, 0)}
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
if err := q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&page.AsOf); err != nil {
return ErrUnavailable
}
var rawColumns, rawActors, rawQuality string
if err := q.QueryRow(ctx, recordsBaseSQL+recordColumnsSQL+recordFilterSQL, append(args, noPublishedMarker)...).Scan(&page.Total, &rawColumns, &rawActors, &rawQuality); err != nil {
return fmt.Errorf("%w: record count failed", ErrUnavailable)
}
if json.Unmarshal([]byte(rawQuality), &page.ScopeQuality) != nil {
return ErrUnavailable
}
for i := range page.ScopeQuality {
page.ScopeQuality[i].ObservedAt = page.AsOf
}
var available map[string]bool
if json.Unmarshal([]byte(rawActors), &page.ScopeActors) != nil {
return ErrUnavailable
}
if err := json.Unmarshal([]byte(rawColumns), &available); err != nil {
return ErrUnavailable
}
for _, column := range recordColumnOrder {
if available[column] {
page.Columns = append(page.Columns, column)
}
}
rows, err := q.Query(ctx, recordsBaseSQL+` SELECT payload::text FROM records WHERE `+recordFilterSQL+`
AND ($5::timestamptz IS NULL OR (asked_at,id)<($5,$6)) ORDER BY asked_at DESC,id DESC LIMIT $7`, append(args, before, cursor.ID, query.Limit+1)...)
if err != nil {
return fmt.Errorf("%w: record list failed", ErrUnavailable)
}
defer rows.Close()
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return ErrUnavailable
}
item, err := decodeRecord(raw)
if err != nil {
return ErrUnavailable
}
page.Items = append(page.Items, item)
}
return rows.Err()
})
if err != nil {
return RecordPage{}, err
}
if len(page.Items) > query.Limit {
page.HasMore = true
page.Items = page.Items[:query.Limit]
last := page.Items[len(page.Items)-1]
raw, err := json.Marshal(recordCursor{AskedAt: last.AskedAt, ID: last.ID, Scope: scope})
if err != nil {
return RecordPage{}, ErrUnavailable
}
page.NextCursor = base64.RawURLEncoding.EncodeToString(raw)
}
return page, nil
}
func (c *Client) GetRecord(ctx context.Context, tenantKey, id string) (Record, error) {
if strings.TrimSpace(tenantKey) == "" || id == "" || len(id) > 256 || strings.ContainsRune(id, 0) {
return Record{}, ErrInvalidQuery
}
var item Record
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
var raw string
err := q.QueryRow(ctx, recordsBaseSQL+` SELECT payload::text FROM records WHERE id=$2`, tenantKey, id).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return ErrRecordNotFound
}
if err != nil {
return ErrUnavailable
}
item, err = decodeRecord(raw)
if err != nil {
return ErrUnavailable
}
return nil
})
return item, err
}
@@ -0,0 +1,79 @@
package dobotaiops
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func qaRecordsFixture(t *testing.T, pool *pgxpool.Pool) string {
t.Helper()
dsn := buildFixture(t, pool)
execFixture(t, pool,
`ALTER TABLE messages ADD COLUMN actor_name_snapshot text NOT NULL DEFAULT '', ADD COLUMN trigger_person_snapshot text NOT NULL DEFAULT '', ADD COLUMN media_json jsonb NOT NULL DEFAULT '[]'`,
`DROP TABLE IF EXISTS answer_feedback`,
`CREATE TABLE answer_feedback(id text PRIMARY KEY,tenant_key text,channel text,turn_id text,answer_message_id text,actor_id text,value text)`,
`GRANT SELECT ON answer_feedback TO kg_ro_fixture`,
`UPDATE messages SET actor_name_snapshot='测试人员',trigger_person_snapshot='回调昵称' WHERE id='msg-req-3'`,
`UPDATE turns SET answer='根据引用,当前暂无已发布的权威知识,暂不建议操作。' WHERE id='turn-3'`,
`INSERT INTO answer_feedback VALUES ('fb1','default','dingtalk','turn-3','msg-ans-3','actor-row-1','up'),('fb2','default','dingtalk','turn-3','msg-ans-3','another-person','down'),('fb3','other-tenant','dingtalk','turn-3','msg-ans-3','actor-row-1','down')`,
)
return dsn
}
func TestQARecordSearchPreservesSourceAndDoesNotMultiplyFeedback(t *testing.T) {
pool := fixturePool(t)
client := buildReadOnlyClient(t, qaRecordsFixture(t, pool))
window := Window{TenantKey: "default", Start: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC), End: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)}
query := RecordQuery{Window: window, Keyword: "第三", Limit: 20}
page, err := client.ListRecords(context.Background(), query)
if err != nil {
t.Fatal(err)
}
if page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("feedback multiplied turns: total=%d rows=%d", page.Total, len(page.Items))
}
item := page.Items[0]
if item.ID != "turn-3" || item.Question != "第三问(正文)" || item.Asker != "测试人员" || item.TriggerPerson != "回调昵称" {
t.Fatalf("explicit field mapping failed: %+v", item)
}
if item.Coverage.Verdict != "uncovered" || item.SourceCoverage.Verdict != "covered" {
t.Fatalf("governance must override reference-prioritized source without rewriting it: %+v", item)
}
if item.Feedback.AskerValue != "up" || item.Feedback.Up != 1 || item.Feedback.Down != 1 {
t.Fatalf("asker and aggregate feedback were conflated: %+v", item.Feedback)
}
query.Keyword = "%"
page, err = client.ListRecords(context.Background(), query)
if err != nil || page.Total != 0 {
t.Fatalf("keyword wildcard must be literal: total=%d err=%v", page.Total, err)
}
query.Keyword = ""
query.Limit = 1
page, err = client.ListRecords(context.Background(), query)
if err != nil || page.Total != 4 || len(page.Items) != 1 || !page.HasMore || page.NextCursor == "" {
t.Fatalf("invalid first page: %+v %v", page, err)
}
firstID := page.Items[0].ID
foundFeedback := false
for _, column := range page.Columns {
if column == "feedback" {
foundFeedback = true
}
}
if !foundFeedback {
t.Fatal("a populated column elsewhere in the filtered result must not be hidden by an empty first page")
}
query.Cursor = page.NextCursor
next, err := client.ListRecords(context.Background(), query)
if err != nil || len(next.Items) != 1 || next.Items[0].ID == firstID {
t.Fatalf("invalid cursor page: %+v %v", next, err)
}
query.Keyword = "changed"
if _, err := client.ListRecords(context.Background(), query); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("cursor must be scoped to its filters: %v", err)
}
}
@@ -0,0 +1,90 @@
package dobotaiops
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Explicit opt-in only. This test executes catalog/SELECT queries, never
// migrations, permission changes, fixture helpers or business writes.
func TestProductionQAQueryReadOnlyVerification(t *testing.T) {
if os.Getenv("KG_DOBOTAI_PROD_VERIFY") != "1" {
t.Skip("explicit production read verification is disabled")
}
path, target := os.Getenv("KG_DOBOTAI_PROD_DSN_FILE"), os.Getenv("KG_DOBOTAI_PROD_SSH_TARGET")
if path == "" || target == "" {
t.Fatal("approved source credential file and SSH target required")
}
client, err := Build(context.Background(), Config{DSNFile: path, GovernanceDatabaseURL: "postgres://governance@not-this-source/governance", DialMode: "ssh", SSHTarget: target, ConnectTimeout: 10 * time.Second, QueryTimeout: 12 * time.Second, MaxConns: 1})
if err != nil {
t.Fatalf("dedicated read source blocked: %v", err)
}
defer client.Close()
now := time.Now().UTC()
start, end := now.Add(-14*24*time.Hour), now.Add(-30*time.Minute)
tenant := os.Getenv("KG_DOBOTAI_PROD_TENANT")
if tenant == "" {
// Discover, never guess: refuse if this approved source is not single-tenant.
var tenants int
err = client.withReadOnlyTx(context.Background(), func(ctx context.Context, q querier) error {
return q.QueryRow(ctx, `SELECT count(DISTINCT tenant_key),COALESCE(min(tenant_key),'') FROM public.turns WHERE channel='dingtalk' AND created_at >= $1 AND created_at < $2`, start, end).Scan(&tenants, &tenant)
})
if err != nil || tenants != 1 || tenant == "" {
t.Fatal("source does not identify one authorized tenant; explicit scope is required")
}
}
query := RecordQuery{Window: Window{TenantKey: tenant, Start: start, End: end}, Limit: 5}
page, err := client.ListRecords(context.Background(), query)
if err != nil {
t.Fatalf("real query implementation failed: %v", err)
}
if page.Total < 1 || len(page.Items) < 1 {
t.Fatal("no real source data in the bounded window")
}
detail, err := client.GetRecord(context.Background(), tenant, page.Items[0].ID)
if err != nil || detail.Question != page.Items[0].Question || detail.Answer != page.Items[0].Answer {
t.Fatal("source list/detail content mismatch")
}
if _, err := client.GetRecord(context.Background(), "qaops-not-authorized-tenant", page.Items[0].ID); !errors.Is(err, ErrRecordNotFound) {
t.Fatal("tenant isolation failed")
}
if page.HasMore {
query.Cursor = page.NextCursor
next, err := client.ListRecords(context.Background(), query)
if err != nil {
t.Fatalf("real cursor page failed: %v", err)
}
ids := map[string]bool{}
for _, item := range page.Items {
ids[item.ID] = true
}
for _, item := range next.Items {
if ids[item.ID] {
t.Fatal("cursor pagination repeated a turn")
}
}
}
safety := client.Safety()
if output := os.Getenv("KG_DOBOTAI_PROD_SCOPE_FILE"); output != "" {
// Optional local browser-harness handoff: one tenant key only, never
// credentials/employee data, outside Git, exclusive creation and 0600.
if !strings.HasPrefix(filepath.Clean(output), filepath.Clean(os.TempDir())+string(os.PathSeparator)) {
t.Fatal("scope handoff must stay inside the scratch directory")
}
file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal("cannot create private scope handoff")
}
_, writeErr := file.WriteString(tenant)
closeErr := file.Close()
if writeErr != nil || closeErr != nil {
t.Fatal("private scope handoff failed")
}
}
t.Logf("REAL_SOURCE_READ_VERIFIED total=%d first_page=%d cursor=%t columns=%v as_of=%s tables_audited=%d effective_table_writes=%d column_writes=%d sequence_writes=%d schema_creates=%d temp=%t ssl=%t; no content or identity logged", page.Total, len(page.Items), page.HasMore, page.Columns, page.AsOf.UTC().Format(time.RFC3339), safety.TablesChecked, safety.TableWrites, safety.ColumnWrites, safety.SequenceWrites, safety.SchemaCreates, safety.DatabaseTemp, safety.SSL)
}
+307
View File
@@ -0,0 +1,307 @@
package dobotaiops
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// 本阶段只读查询模块(工单第 4 项)。纪律:
// - 一律绑定参数;列固定;不拼接调用方给的 SQL/排序字段;
// - 时间统一半开区间 created_at >= $start AND created_at < $end;
// - 按 turn 计次;跨租户/渠道的连接同时校验 tenant_key 与 channel;
// - 只走 REPEATABLE READ + READ ONLY 事务,口径固定在同一个 as_of 上。
const countsTotalSQL = `
SELECT count(*)::bigint
FROM turns
WHERE tenant_key = $1 AND created_at >= $2 AND created_at < $3
AND ($4 = '' OR channel = $4) AND ($5 = '' OR status = $5)`
const countsBySQL = `
SELECT %s, count(*)::bigint
FROM turns
WHERE tenant_key = $1 AND created_at >= $2 AND created_at < $3
AND ($4 = '' OR channel = $4) AND ($5 = '' OR status = $5)
GROUP BY 1
ORDER BY 1`
// QueryCounts 返回某租户在窗口内的问答总量与状态/渠道分组(按 turn 计次)。
func (c *Client) QueryCounts(ctx context.Context, window Window, filter Filter) (CountsResult, error) {
if err := filter.Validate(); err != nil {
return CountsResult{}, err
}
if err := c.validateQueryWindow(window); err != nil {
return CountsResult{}, err
}
result := CountsResult{TenantKey: window.TenantKey, Start: window.Start, End: window.End,
ByStatus: map[string]int64{}, ByChannel: map[string]int64{}}
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
if err := q.QueryRow(ctx, countsTotalSQL, window.TenantKey, window.Start, window.End, filter.Channel, filter.Status).
Scan(&result.Total); err != nil {
return fmt.Errorf("%w: total count failed", ErrUnavailable)
}
statusRows, err := q.Query(ctx, fmt.Sprintf(countsBySQL, "status"), window.TenantKey, window.Start, window.End, filter.Channel, filter.Status)
if err != nil {
return fmt.Errorf("%w: status grouping failed", ErrUnavailable)
}
defer statusRows.Close()
for statusRows.Next() {
var status string
var count int64
if err := statusRows.Scan(&status, &count); err != nil {
return fmt.Errorf("%w: status grouping failed", ErrUnavailable)
}
result.ByStatus[status] = count
}
channelRows, err := q.Query(ctx, fmt.Sprintf(countsBySQL, "channel"), window.TenantKey, window.Start, window.End, filter.Channel, filter.Status)
if err != nil {
return fmt.Errorf("%w: channel grouping failed", ErrUnavailable)
}
defer channelRows.Close()
for channelRows.Next() {
var channel string
var count int64
if err := channelRows.Scan(&channel, &count); err != nil {
return fmt.Errorf("%w: channel grouping failed", ErrUnavailable)
}
result.ByChannel[channel] = count
}
if err := statusRows.Err(); err != nil {
return fmt.Errorf("%w: status grouping failed", ErrUnavailable)
}
return channelRows.Err()
})
if err != nil {
return CountsResult{}, err
}
return result, nil
}
const coverageByVersionSQL = `
SELECT c.evaluator_version, c.verdict, c.reason_code, count(DISTINCT c.turn_id)::bigint
FROM coverage_decisions c
JOIN turns t ON t.id = c.turn_id AND t.tenant_key = c.tenant_key AND t.channel = c.channel
WHERE t.tenant_key = $1 AND t.created_at >= $2 AND t.created_at < $3
GROUP BY 1, 2, 3
ORDER BY 1, 2, 3`
const succeededTurnStatsSQL = `
SELECT count(*)::bigint,
count(*) FILTER (
WHERE NOT EXISTS (
SELECT 1 FROM coverage_decisions c
WHERE c.turn_id = t.id AND c.tenant_key = t.tenant_key AND c.channel = t.channel
)
)::bigint
FROM turns t
WHERE t.tenant_key = $1 AND t.status = 'succeeded' AND t.created_at >= $2 AND t.created_at < $3`
// QueryRuleCoverage 返回规则覆盖分组与"成功 turn 缺决策"量。
// 注意:这是 rule_coverage(规则决策),**不是** AI 正确率或正式知识覆盖率;
// 不同 evaluator_version 的口径不会混算。
func (c *Client) QueryRuleCoverage(ctx context.Context, window Window) (CoverageResult, error) {
if err := c.validateQueryWindow(window); err != nil {
return CoverageResult{}, err
}
result := CoverageResult{NotAnAccuracyMeasure: "rule_coverage 只是规则决策覆盖;v5 的 covered 含非知识响应/纯视觉识别,不能当 AI 正确率或正式知识覆盖率"}
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
rows, err := q.Query(ctx, coverageByVersionSQL, window.TenantKey, window.Start, window.End)
if err != nil {
return fmt.Errorf("%w: coverage grouping failed", ErrUnavailable)
}
defer rows.Close()
for rows.Next() {
var group CoverageGroup
if err := rows.Scan(&group.EvaluatorVersion, &group.Verdict, &group.ReasonCode, &group.Turns); err != nil {
return fmt.Errorf("%w: coverage grouping failed", ErrUnavailable)
}
result.Groups = append(result.Groups, group)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("%w: coverage grouping failed", ErrUnavailable)
}
if err := q.QueryRow(ctx, succeededTurnStatsSQL, window.TenantKey, window.Start, window.End).
Scan(&result.SucceededTurns, &result.SucceededWithoutDecision); err != nil {
return fmt.Errorf("%w: succeeded turn stats failed", ErrUnavailable)
}
return nil
})
if err != nil {
return CoverageResult{}, err
}
return result, nil
}
const listTurnMetadataSQL = `
SELECT t.id, t.created_at, t.status, t.channel, t.agent_id, t.knowledge_searched,
CASE WHEN jsonb_typeof(t.references_json) = 'array' THEN jsonb_array_length(t.references_json) ELSE 0 END,
linked.total,
COALESCE(linked.department_missing, TRUE),
COALESCE(linked.region_missing, TRUE)
FROM turns t
LEFT JOIN LATERAL (
SELECT count(*)::int AS total,
bool_and(COALESCE(m.department_snapshot, '') = '') AS department_missing,
bool_and(COALESCE(m.region_snapshot, '') = '') AS region_missing
FROM messages m
WHERE m.tenant_key = t.tenant_key AND m.channel = t.channel
AND m.id = ANY(array_remove(ARRAY[t.request_message_id, t.answer_message_id], NULL))
) linked ON TRUE
WHERE t.tenant_key = $1 AND t.created_at >= $2 AND t.created_at < $3
AND ($4::timestamptz IS NULL OR (t.created_at, t.id) < ($4::timestamptz, $5::text))
ORDER BY t.created_at DESC, t.id DESC
LIMIT $6`
// ListTurnMetadata 用 (created_at, id) 游标稳定翻页(倒序);limit 硬上限 100。
// 返回的是元数据:不含正文、不含员工个人值;部门/区域快照为空如实标记为 missing。
func (c *Client) ListTurnMetadata(ctx context.Context, window Window, cursor string, limit int) (TurnMetadataPage, error) {
if err := c.validateQueryWindow(window); err != nil {
return TurnMetadataPage{}, err
}
if limit <= 0 {
limit = 50
}
if limit > c.config.MaxPageSize {
return TurnMetadataPage{}, fmt.Errorf("%w: limit exceeds %d", ErrInvalidQuery, c.config.MaxPageSize)
}
var decoded Cursor
var cursorTime *time.Time
if strings.TrimSpace(cursor) != "" {
value, err := DecodeCursor(cursor)
if err != nil {
return TurnMetadataPage{}, err
}
decoded = value
cursorTime = &decoded.CreatedAt
}
page := TurnMetadataPage{}
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
rows, err := q.Query(ctx, listTurnMetadataSQL, window.TenantKey, window.Start, window.End, cursorTime, decoded.ID, limit+1)
if err != nil {
return fmt.Errorf("%w: turn listing failed", ErrUnavailable)
}
defer rows.Close()
for rows.Next() {
var item TurnMetadata
if err := rows.Scan(&item.ID, &item.CreatedAt, &item.Status, &item.Channel, &item.AgentID,
&item.KnowledgeSearched, &item.ReferenceCount, &item.LinkedMessages,
&item.DepartmentMissing, &item.RegionMissing); err != nil {
return fmt.Errorf("%w: turn listing failed", ErrUnavailable)
}
page.Items = append(page.Items, item)
}
return rows.Err()
})
if err != nil {
return TurnMetadataPage{}, err
}
if len(page.Items) > limit {
// 多取一条只为判断是否还有下一页;游标必须指向**最后一条真正返回的行**,
// 否则下一页会跳过那一条(集成测试专门钉这个边界)。
page.Items = page.Items[:limit]
page.HasMore = true
tail := page.Items[len(page.Items)-1]
page.NextCursor = EncodeCursor(Cursor{CreatedAt: tail.CreatedAt.UTC(), ID: tail.ID})
}
return page, nil
}
const authorizedTurnSQL = `
SELECT t.id, t.created_at, t.status, t.channel, t.agent_id, t.question, t.answer,
COALESCE(t.references_json::text, '[]'), t.error_message
FROM turns t
WHERE t.tenant_key = $1 AND t.id = $2`
const authorizedMessagesSQL = `
SELECT m.id, m.created_at, m.direction, m.role, m.message_type, m.status, m.text_content,
a.id IS NOT NULL,
COALESCE(a.actor_type, '')
FROM messages m
LEFT JOIN channel_actors a
-- 关联字段必须是 channel_actors.id:生产 FK 是 messages.actor_id → channel_actors(id)。
-- 用 external_actor_id 关联在生产对拍里命中 0 行(正确关联 2234 行)。
ON a.tenant_key = m.tenant_key AND a.channel = m.channel AND a.id = m.actor_id
WHERE m.tenant_key = $1 AND m.channel = $2
AND m.id = ANY(array_remove(ARRAY[$3, $4], NULL))
ORDER BY m.created_at, m.id`
// GetTurnForAuthorizedRead 按租户 + turn_id 读取正文/引用(必要时)。
// **必须**传入授权回调(返回错误即拒绝);本函数不提供"默认放行",测试输出也不应打印原文或员工个人值。
func (c *Client) GetTurnForAuthorizedRead(ctx context.Context, tenantKey, turnID string, authorize func(turnID string) error) (TurnDetail, error) {
if authorize == nil {
return TurnDetail{}, fmt.Errorf("%w: an explicit authorization callback is required", ErrInvalidQuery)
}
if strings.TrimSpace(tenantKey) == "" || strings.TrimSpace(turnID) == "" {
return TurnDetail{}, fmt.Errorf("%w: tenant and turn id are required", ErrInvalidQuery)
}
if err := authorize(turnID); err != nil {
return TurnDetail{}, fmt.Errorf("%w: turn read is not authorized", ErrInvalidQuery)
}
detail := TurnDetail{}
var references string
var requestID, answerID *string
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
if err := q.QueryRow(ctx, authorizedTurnSQL, tenantKey, turnID).Scan(
&detail.ID, &detail.CreatedAt, &detail.Status, &detail.Channel, &detail.AgentID,
&detail.Question, &detail.Answer, &references, &detail.ErrorCode); err != nil {
return fmt.Errorf("%w: turn could not be read", ErrUnavailable)
}
detail.ReferencesJSON = []byte(references)
if !json.Valid(detail.ReferencesJSON) {
detail.ReferencesJSON = []byte("[]")
}
// request/answer 两条消息 id 需要一起取:单独查一次拿 id 列表,避免把正文带进列表接口。
if err := q.QueryRow(ctx, `SELECT request_message_id, answer_message_id FROM turns WHERE tenant_key = $1 AND id = $2`, tenantKey, turnID).
Scan(&requestID, &answerID); err != nil {
return fmt.Errorf("%w: turn could not be read", ErrUnavailable)
}
rows, err := q.Query(ctx, authorizedMessagesSQL, tenantKey, detail.Channel, requestID, answerID)
if err != nil {
return fmt.Errorf("%w: turn messages could not be read", ErrUnavailable)
}
defer rows.Close()
for rows.Next() {
var message TurnMessage
if err := rows.Scan(&message.ID, &message.CreatedAt, &message.Direction, &message.Role,
&message.MessageType, &message.Status, &message.Text, &message.ActorLinked, &message.ActorType); err != nil {
return fmt.Errorf("%w: turn messages could not be read", ErrUnavailable)
}
detail.Messages = append(detail.Messages, message)
}
return rows.Err()
})
if err != nil {
return TurnDetail{}, err
}
return detail, nil
}
// validateQueryWindow 是仓储入口的**完整**校验:不能只靠 NewWindow —— 调用方完全可以
// 自己拼一个 Window 结构体绕过它(独立探测已证明)。这里把租户、半开区间、跨度上限
// 与"不允许未来窗口"全部落实在每次查询上。
func (c *Client) validateQueryWindow(window Window) error {
if strings.TrimSpace(window.TenantKey) == "" {
return fmt.Errorf("%w: tenant key is required", ErrInvalidQuery)
}
if window.Start.IsZero() || window.End.IsZero() || !window.Start.Before(window.End) {
return fmt.Errorf("%w: window is invalid", ErrInvalidQuery)
}
limit := c.config.MaxWindow
if limit <= 0 {
limit = 90 * 24 * time.Hour
}
if window.End.Sub(window.Start) > limit {
return fmt.Errorf("%w: window exceeds the %s limit", ErrInvalidQuery, limit)
}
now := time.Now()
if c.config.Now != nil {
now = c.config.Now()
}
if window.End.After(now.Add(5 * time.Minute)) {
return fmt.Errorf("%w: window end is in the future", ErrInvalidQuery)
}
return nil
}
@@ -0,0 +1,347 @@
package dobotaiops
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// 这些用例只在 TEST_DATABASE_URL 指向**一次性/隔离**数据库时运行(CI 默认跳过)。
// 它们**绝不**指向生产:fixture 表、隔离角色、临时 DSN 文件都在这个库内建/删。
// 生产侧的真实验证另有入口(纯目录/SELECT,见工单与 runbook)。
func fixturePool(t *testing.T) *pgxpool.Pool {
t.Helper()
// 先过隔离环境护栏(会建表/建角色):未声明就跳过,声明了但不像一次性库就直接失败。
fixtureEnvironment(t)
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
return pool
}
// dropRoleIfExists 先解掉授权依赖再删角色(临时库里可重复运行)。
func dropRoleIfExists(t *testing.T, pool *pgxpool.Pool, role string) {
t.Helper()
var exists bool
if err := pool.QueryRow(context.Background(), `SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = $1)`, role).Scan(&exists); err != nil {
t.Fatal(err)
}
if !exists {
return
}
execFixture(t, pool, fmt.Sprintf(`DROP OWNED BY %s`, quoteIdent(role)), fmt.Sprintf(`DROP ROLE %s`, quoteIdent(role)))
}
func execFixture(t *testing.T, pool *pgxpool.Pool, statements ...string) {
t.Helper()
for _, statement := range statements {
if _, err := pool.Exec(context.Background(), statement); err != nil {
t.Fatalf("fixture 语句失败(%s):%v", strings.SplitN(statement, "\n", 2)[0], err)
}
}
}
func randomSecret(t *testing.T) string {
t.Helper()
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
t.Fatal(err)
}
return strings.NewReplacer("+", "A", "/", "B", "=", "C").Replace(base64.URLEncoding.EncodeToString(buf))
}
func writeSecretFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "dobotai_ops_ro.env")
if err := os.WriteFile(path, []byte(content+"\n"), 0o600); err != nil {
t.Fatal(err)
}
return path
}
// buildFixture 建表 + 灌数据 + 建一个镜像生产授权面的最小只读角色,返回只读 DSN。
func buildFixture(t *testing.T, pool *pgxpool.Pool) string {
t.Helper()
ctx := context.Background()
execFixture(t, pool,
`DROP TABLE IF EXISTS messages, coverage_decisions, channel_actors, turns CASCADE`,
`CREATE TABLE turns (id text PRIMARY KEY, tenant_key text NOT NULL, channel text NOT NULL,
status text NOT NULL, agent_id text NOT NULL DEFAULT '', knowledge_searched boolean NOT NULL DEFAULT false,
references_json jsonb NOT NULL DEFAULT '[]'::jsonb, request_message_id text, answer_message_id text,
question text NOT NULL DEFAULT '', answer text NOT NULL DEFAULT '', error_message text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL)`,
`CREATE TABLE messages (id text PRIMARY KEY, tenant_key text NOT NULL, channel text NOT NULL, actor_id text,
department_snapshot text NOT NULL DEFAULT '', region_snapshot text NOT NULL DEFAULT '', actor_union_id_snapshot text NOT NULL DEFAULT '',
direction text NOT NULL DEFAULT '', role text NOT NULL DEFAULT '', message_type text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT '', text_content text NOT NULL DEFAULT '', created_at timestamptz NOT NULL)`,
`CREATE TABLE coverage_decisions (id text PRIMARY KEY, tenant_key text NOT NULL, channel text NOT NULL,
turn_id text NOT NULL, evaluator_version text NOT NULL, verdict text NOT NULL, reason_code text NOT NULL DEFAULT '',
reference_count integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL,
UNIQUE (tenant_key, channel, turn_id))`,
`CREATE TABLE channel_actors (id text PRIMARY KEY, tenant_key text NOT NULL, channel text NOT NULL,
external_actor_id text NOT NULL, actor_type text NOT NULL, display_name text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL)`,
`INSERT INTO turns (id, tenant_key, channel, status, agent_id, knowledge_searched, references_json,
request_message_id, answer_message_id, question, answer, created_at) VALUES
('turn-4', 'default', 'dingtalk', 'succeeded', 'agent-a', false, '[]',
'msg-req-4', 'msg-ans-4', '第四问(无规则决策)', '第四答', '2026-10-14T04:00:00Z'),
('turn-3', 'default', 'dingtalk', 'succeeded', 'agent-a', true, '[{"doc":"x"},{"doc":"y"}]',
'msg-req-3', 'msg-ans-3', '第三问(正文)', '第三答(正文)', '2026-10-14T03:00:00Z'),
('turn-2', 'default', 'dingtalk', 'succeeded', 'agent-a', false, '[]',
'msg-req-2', 'msg-ans-2', '第二问', '第二答', '2026-10-14T02:00:00Z'),
('turn-1', 'default', 'dingtalk', 'failed', 'agent-b', true, '[]',
NULL, NULL, '第一问', '', '2026-10-14T01:00:00Z')`,
`INSERT INTO messages (id, tenant_key, channel, actor_id, department_snapshot, region_snapshot,
direction, role, message_type, status, text_content, created_at) VALUES
('msg-req-3', 'default', 'dingtalk', 'actor-row-1', '', '', 'inbound', 'user', 'text', 'ok', '第三问原文', '2026-10-14T03:00:00Z'),
('msg-ans-3', 'default', 'dingtalk', 'actor-row-1', '研发部', '华南', 'outbound', 'assistant', 'text', 'ok', '第三答原文', '2026-10-14T03:00:01Z'),
('msg-req-2', 'default', 'dingtalk', 'actor-row-1', '', '', 'inbound', 'user', 'text', 'ok', '第二问原文', '2026-10-14T02:00:00Z'),
('msg-req-4', 'default', 'dingtalk', 'actor-row-1', '', '', 'inbound', 'user', 'text', 'ok', '第四问原文', '2026-10-14T04:00:00Z'),
('msg-ans-4', 'default', 'dingtalk', 'actor-row-1', '', '', 'outbound', 'assistant', 'text', 'ok', '第四答原文', '2026-10-14T04:00:01Z')`,
// 真实关系:messages.actor_id 引用 channel_actors(id);external_actor_id 是另一个
// 唯一键(tenant_key, channel, external_actor_id)。夹具必须照这个来 —— 用
// external_actor_id 关联就会命中 0 行(生产对拍实测:错 0 / 对 2234)。
`INSERT INTO channel_actors (id, tenant_key, channel, external_actor_id, actor_type, display_name, created_at)
VALUES ('actor-row-1', 'default', 'dingtalk', 'ext-actor-1', 'employee', '不应外泄的姓名', '2026-10-14T00:00:00Z')`,
// 另一个租户的数据:所有查询都必须把它挡在外面(跨租户隔离)。
`INSERT INTO turns (id, tenant_key, channel, status, agent_id, created_at)
VALUES ('turn-other-tenant', 'another-tenant', 'dingtalk', 'succeeded', 'agent-x', '2026-10-14T03:30:00Z')`,
`INSERT INTO coverage_decisions (id, tenant_key, channel, turn_id, evaluator_version, verdict, reason_code, reference_count, created_at)
VALUES ('cov-other-tenant', 'another-tenant', 'dingtalk', 'turn-other-tenant', 'v5', 'covered', '', 0, '2026-10-14T03:30:02Z')`,
// 生产上 (tenant_key, channel, turn_id) 是唯一的:一个 turn 只会有一条决策,
// 不同 turn 才可能来自不同 evaluator_version —— fixture 照生产形态来。
`INSERT INTO coverage_decisions (id, tenant_key, channel, turn_id, evaluator_version, verdict, reason_code, reference_count, created_at)
VALUES ('cov-1', 'default', 'dingtalk', 'turn-3', 'v5', 'covered', '', 2, '2026-10-14T03:00:02Z'),
('cov-2', 'default', 'dingtalk', 'turn-1', 'v5', 'uncovered', 'no_source', 0, '2026-10-14T01:00:02Z'),
('cov-3', 'default', 'dingtalk', 'turn-2', 'v4', 'covered', '', 0, '2026-10-14T02:00:02Z')`,
)
password := randomSecret(t)
// 角色在同一个临时库里可能已经存在(多个用例复用):先删再建,保持用例可重复运行。
dropRoleIfExists(t, pool, "kg_ro_fixture")
execFixture(t, pool,
fmt.Sprintf(`CREATE ROLE kg_ro_fixture LOGIN PASSWORD '%s' CONNECTION LIMIT 4`, password),
`ALTER ROLE kg_ro_fixture SET default_transaction_read_only = on`,
)
// 当前库名(避免写死)
var databaseName string
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
t.Fatal(err)
}
execFixture(t, pool,
fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO kg_ro_fixture`, quoteIdent(databaseName)),
`GRANT USAGE ON SCHEMA public TO kg_ro_fixture`,
`GRANT SELECT ON TABLE public.turns, public.messages, public.coverage_decisions, public.channel_actors TO kg_ro_fixture`,
)
// 用与生产一致的写法拼只读 DSN:主机/端口/库名从 TEST_DATABASE_URL 派生
//(容器内 inet_server_addr 从宿主机不可路由),口令在测试内不打印。
return dsnWithCredentials(t, "kg_ro_fixture", password)
}
// dsnWithCredentials 把 TEST_DATABASE_URL 里的用户/口令换掉,其余(主机、端口、库、查询串)保持。
func dsnWithCredentials(t *testing.T, user, password string) string {
t.Helper()
parsed, err := url.Parse(strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")))
if err != nil {
t.Fatal(err)
}
parsed.User = url.UserPassword(user, password)
return parsed.String()
}
func quoteIdent(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
func buildReadOnlyClient(t *testing.T, roDSN string) *Client {
t.Helper()
client, err := Build(context.Background(), Config{
DSNFile: writeSecretFile(t, "DOBOTAI_OPS_READ_DSN="+roDSN),
GovernanceDatabaseURL: "postgres://governance@elsewhere:5432/governance",
DialMode: "direct",
ConnectTimeout: 5 * time.Second,
QueryTimeout: 5 * time.Second,
MaxConns: 2,
// 固定"当前时间":仓储入口会拒未来窗口,夹具的时间窗必须相对一个确定时刻来判。
Now: func() time.Time { return time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("Build() 失败:%v", err)
}
t.Cleanup(client.Close)
return client
}
func TestReadOnlyRepositoryAgainstFixtureDatabase(t *testing.T) {
pool := fixturePool(t)
roDSN := buildFixture(t, pool)
client := buildReadOnlyClient(t, roDSN)
ctx := context.Background()
window, err := NewWindow("default", "2026-10-14T00:00:00Z", "2026-10-15T00:00:00Z", 90*24*time.Hour, time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
// ACL 自检报告必须干净(这是"账号确实最小只读"的机器证据)。
report := client.ACL()
if report.Role != "kg_ro_fixture" || report.Super || report.SchemaCreate || len(report.WritePrivileges) != 0 || len(report.MissingSelects) != 0 {
t.Fatalf("ACL 报告不干净:%+v", report)
}
counts, err := client.QueryCounts(ctx, window, Filter{})
if err != nil {
t.Fatalf("QueryCounts() error = %v", err)
}
if counts.Total != 4 || counts.ByStatus["succeeded"] != 3 || counts.ByStatus["failed"] != 1 || counts.ByChannel["dingtalk"] != 4 {
t.Fatalf("counts = %+v", counts)
}
filtered, err := client.QueryCounts(ctx, window, Filter{Channel: "dingtalk", Status: "succeeded"})
if err != nil || filtered.Total != 3 {
t.Fatalf("过滤计数 = %+v (%v)", filtered, err)
}
if _, err := client.QueryCounts(ctx, window, Filter{Status: "nope"}); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("未知状态应被拒:%v", err)
}
coverage, err := client.QueryRuleCoverage(ctx, window)
if err != nil {
t.Fatalf("QueryRuleCoverage() error = %v", err)
}
if len(coverage.Groups) != 3 {
t.Fatalf("覆盖分组 = %+v", coverage.Groups)
}
if coverage.SucceededTurns != 3 || coverage.SucceededWithoutDecision != 1 {
t.Fatalf("成功 turn 统计 = %+v", coverage)
}
for _, group := range coverage.Groups {
if group.EvaluatorVersion == "v5" && group.Verdict == "uncovered" && group.ReasonCode != "no_source" {
t.Fatalf("reason_code 未保留:%+v", group)
}
}
first, err := client.ListTurnMetadata(ctx, window, "", 2)
if err != nil {
t.Fatalf("ListTurnMetadata() error = %v", err)
}
if len(first.Items) != 2 || !first.HasMore || first.NextCursor == "" {
t.Fatalf("第一页 = %+v", first)
}
if first.Items[0].ID != "turn-4" || first.Items[1].ID != "turn-3" {
t.Fatalf("倒序不对:%+v", first.Items)
}
if first.Items[1].ReferenceCount != 2 || first.Items[1].LinkedMessages != 2 {
t.Fatalf("引用/关联消息计数不对:%+v", first.Items[1])
}
// turn-3:一条关联消息部门/区域为空、一条有值 ⇒ 不该标 missing;turn-4:两条都为空 ⇒ 如实标 missing。
if first.Items[1].DepartmentMissing || first.Items[1].RegionMissing {
t.Fatalf("有值的关联消息不该标 missing:%+v", first.Items[1])
}
if !first.Items[0].DepartmentMissing || !first.Items[0].RegionMissing {
t.Fatalf("快照为空的关联消息必须如实标 missing:%+v", first.Items[0])
}
second, err := client.ListTurnMetadata(ctx, window, first.NextCursor, 2)
if err != nil {
t.Fatalf("第二页 error = %v", err)
}
if len(second.Items) != 2 || second.Items[0].ID != "turn-2" || second.Items[1].ID != "turn-1" || second.HasMore {
t.Fatalf("第二页 = %+v", second)
}
if _, err := client.ListTurnMetadata(ctx, window, "不是游标", 10); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("非法游标应被拒:%v", err)
}
if _, err := client.ListTurnMetadata(ctx, window, "", 101); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("超过分页上限应被拒:%v", err)
}
// 跨租户隔离:另一个租户的 turn 不能出现在任何结果里(计数、覆盖、列表都一样)。
otherTenant, err := NewWindow("another-tenant", "2026-10-14T00:00:00Z", "2026-10-15T00:00:00Z", 90*24*time.Hour, time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
otherCounts, err := client.QueryCounts(ctx, otherTenant, Filter{})
if err != nil || otherCounts.Total != 1 {
t.Fatalf("另一个租户应只看到自己的 1 条:%+v (%v)", otherCounts, err)
}
for _, item := range append(first.Items, second.Items...) {
if item.ID == "turn-other-tenant" {
t.Fatalf("跨租户泄漏:%+v", item)
}
}
if _, err := client.GetTurnForAuthorizedRead(ctx, "default", "turn-other-tenant", func(string) error { return nil }); !errors.Is(err, ErrUnavailable) {
// default 租户下没有这个 turn ⇒ 读不到(不是"读到了别人的")
t.Fatalf("跨租户 turn 不该在 default 下可读:%v", err)
}
// 授权读取:没有授权回调一律拒绝(不提供"默认放行")。
if _, err := client.GetTurnForAuthorizedRead(ctx, "default", "turn-3", nil); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("缺授权回调必须拒绝:%v", err)
}
if _, err := client.GetTurnForAuthorizedRead(ctx, "default", "turn-3", func(string) error { return errors.New("no") }); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("授权回调拒绝时必须拒绝:%v", err)
}
detail, err := client.GetTurnForAuthorizedRead(ctx, "default", "turn-3", func(string) error { return nil })
if err != nil {
t.Fatalf("授权读取 error = %v", err)
}
if detail.Question != "第三问(正文)" || detail.Answer != "第三答(正文)" || len(detail.Messages) != 2 {
t.Fatalf("授权读取内容不对:%+v", detail)
}
// 关联回归:即使夹具里 id 与 external_actor_id 不同,也必须靠正确的字段命中 actor_type。
for _, message := range detail.Messages {
if message.ActorType != "employee" {
t.Fatalf("人员关联没命中(关联字段必须是 channel_actors.id):%+v", message)
}
}
if strings.Contains(detail.Messages[0].ActorType, "不应外泄") {
t.Fatalf("只应带 actor_type,不应带员工姓名:%+v", detail.Messages[0])
}
_ = client.Available(ctx)
}
func TestBuildRefusesUnsafeAccountsAndTargets(t *testing.T) {
pool := fixturePool(t)
roDSN := buildFixture(t, pool)
ctx := context.Background()
password := randomSecret(t)
dropRoleIfExists(t, pool, "kg_writer_fixture")
execFixture(t, pool,
fmt.Sprintf(`CREATE ROLE kg_writer_fixture LOGIN PASSWORD '%s'`, password),
`GRANT USAGE ON SCHEMA public TO kg_writer_fixture`,
`GRANT SELECT, INSERT ON TABLE public.turns TO kg_writer_fixture`,
`GRANT SELECT ON TABLE public.messages, public.coverage_decisions, public.channel_actors TO kg_writer_fixture`,
)
writerDSN := dsnWithCredentials(t, "kg_writer_fixture", password)
// 有写权限的账号:fail closed(不是"能读就算过")。
_, err := Build(ctx, Config{
DSNFile: writeSecretFile(t, writerDSN), DialMode: "direct",
GovernanceDatabaseURL: "postgres://governance@elsewhere:5432/governance",
ConnectTimeout: 5 * time.Second, QueryTimeout: 5 * time.Second})
if !errors.Is(err, ErrNotReadOnly) {
t.Fatalf("可写账号必须被拒为 ErrNotReadOnly:%v", err)
}
// 指向治理主库的 DSN:拒绝(防"误配后读写治理库")。
_, err = Build(ctx, Config{
DSNFile: writeSecretFile(t, roDSN), DialMode: "direct", GovernanceDatabaseURL: roDSN,
ConnectTimeout: 5 * time.Second, QueryTimeout: 5 * time.Second})
if !errors.Is(err, ErrNotReadOnly) {
t.Fatalf("指向治理库必须被拒:%v", err)
}
// secret 文件缺失:ErrUnavailable(按"未启用"处理,不拖垮治理)。
_, err = Build(ctx, Config{DSNFile: filepath.Join(t.TempDir(), "missing.env"), DialMode: "direct",
ConnectTimeout: time.Second, QueryTimeout: time.Second})
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("缺 secret 应为 ErrUnavailable:%v", err)
}
}
+71
View File
@@ -0,0 +1,71 @@
package dobotaiops
import (
"context"
"encoding/json"
"fmt"
)
// SourceSafety records effective privileges, not only grants on queried tables.
// TEMP and transport are disclosed independently: this probe never changes them.
type SourceSafety struct {
Database string `json:"database"`
ConnectionLimit int `json:"connection_limit"`
TablesChecked int `json:"tables_checked"`
TableWrites int `json:"table_writes"`
ColumnWrites int `json:"column_writes"`
SequenceWrites int `json:"sequence_writes"`
SchemaCreates int `json:"schema_creates"`
DatabaseTemp bool `json:"database_temp"`
SSL bool `json:"ssl"`
MissingColumns []string `json:"missing_columns"`
}
const sourceSafetySQL = `
WITH relations AS (
SELECT c.oid,c.relkind FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname<>'information_schema'
), required(table_name,column_name,type_name) AS (VALUES
('turns','id','text'),('turns','tenant_key','text'),('turns','channel','text'),
('turns','question','text'),('turns','answer','text'),('turns','created_at','timestamptz'),
('turns','references_json','jsonb'),('turns','knowledge_searched','bool'),
('turns','request_message_id','text'),('turns','answer_message_id','text'),
('messages','id','text'),('messages','actor_id','text'),('messages','department_snapshot','text'),
('messages','region_snapshot','text'),('messages','actor_union_id_snapshot','text'),('channel_actors','id','text'),('channel_actors','external_actor_id','text'),
('coverage_decisions','turn_id','text'),('coverage_decisions','verdict','text')
)
SELECT jsonb_build_object(
'database',current_database(),
'connection_limit',(SELECT rolconnlimit FROM pg_roles WHERE rolname=current_user),
'tables_checked',(SELECT count(*) FROM relations WHERE relkind IN ('r','p','v','m','f')),
'table_writes',(SELECT count(*) FROM relations WHERE relkind IN ('r','p','v','m','f') AND (
has_table_privilege(oid,'INSERT') OR has_table_privilege(oid,'UPDATE') OR has_table_privilege(oid,'DELETE') OR
has_table_privilege(oid,'TRUNCATE') OR has_table_privilege(oid,'REFERENCES') OR has_table_privilege(oid,'TRIGGER') OR
CASE WHEN current_setting('server_version_num')::int>=170000 THEN has_table_privilege(oid,'MAINTAIN') ELSE false END)),
'column_writes',(SELECT count(*) FROM relations WHERE relkind IN ('r','p','v','m','f') AND (
has_any_column_privilege(oid,'INSERT') OR has_any_column_privilege(oid,'UPDATE') OR has_any_column_privilege(oid,'REFERENCES'))),
'sequence_writes',(SELECT count(*) FROM relations WHERE relkind='S' AND (has_sequence_privilege(oid,'USAGE') OR has_sequence_privilege(oid,'UPDATE'))),
'schema_creates',(SELECT count(*) FROM pg_namespace WHERE nspname NOT LIKE 'pg_%' AND nspname<>'information_schema' AND has_schema_privilege(oid,'CREATE')),
'database_temp',has_database_privilege(current_database(),'TEMP'),
'ssl',COALESCE((SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()),false),
'missing_columns',(SELECT COALESCE(jsonb_agg(table_name||'.'||column_name),'[]'::jsonb) FROM required r WHERE NOT EXISTS(
SELECT 1 FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='public' AND c.relname=r.table_name AND a.attname=r.column_name AND a.attnum>0 AND NOT a.attisdropped AND a.atttypid=to_regtype(r.type_name))))::text`
func AuditSourceSafety(ctx context.Context, q rowQuerier) (SourceSafety, error) {
var raw string
if err := q.QueryRow(ctx, sourceSafetySQL).Scan(&raw); err != nil {
return SourceSafety{}, fmt.Errorf("%w: extended safety probe failed", ErrUnavailable)
}
var report SourceSafety
if err := json.Unmarshal([]byte(raw), &report); err != nil || report.Database == "" || report.TablesChecked < 4 || report.ConnectionLimit <= 0 {
return SourceSafety{}, fmt.Errorf("%w: incomplete safety report or unlimited source connections", ErrNotReadOnly)
}
if report.TableWrites > 0 || report.ColumnWrites > 0 || report.SequenceWrites > 0 || report.SchemaCreates > 0 {
return report, fmt.Errorf("%w: effective write or maintenance privileges detected", ErrNotReadOnly)
}
if len(report.MissingColumns) > 0 {
return report, fmt.Errorf("%w: source schema contract mismatch", ErrUnavailable)
}
return report, nil
}
@@ -0,0 +1,20 @@
package dobotaiops
import (
"context"
"errors"
"testing"
"time"
)
func TestSourceRechecksEffectivePrivilegesBeforeEveryQuery(t *testing.T) {
pool := fixturePool(t)
roDSN := buildFixture(t, pool)
client := buildReadOnlyClient(t, roDSN)
execFixture(t, pool, `CREATE TABLE acl_other_surface(id text)`, `GRANT INSERT ON acl_other_surface TO kg_ro_fixture`)
t.Cleanup(func() { execFixture(t, pool, `DROP TABLE IF EXISTS acl_other_surface`) })
window := Window{TenantKey: "default", Start: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC), End: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)}
if _, err := client.QueryCounts(context.Background(), window, Filter{}); !errors.Is(err, ErrNotReadOnly) {
t.Fatalf("unexpected privilege escalation outside legacy tables was not blocked: %v", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package dobotaiops
import (
"context"
"encoding/json"
"errors"
"testing"
)
func TestExtendedAuditRejectsWritesOutsideTheFourLegacyTables(t *testing.T) {
for _, key := range []string{"table_writes", "column_writes", "sequence_writes", "schema_creates"} {
report := map[string]any{"database": "source_test", "connection_limit": 4, "tables_checked": 19, "table_writes": 0, "column_writes": 0, "sequence_writes": 0, "schema_creates": 0, "database_temp": true, "ssl": false, "missing_columns": []string{}}
report[key] = 1
raw, _ := json.Marshal(report)
_, err := AuditSourceSafety(context.Background(), fakeQuerier{row: fakeRow{values: []any{string(raw)}}})
if !errors.Is(err, ErrNotReadOnly) {
t.Fatalf("%s must block source: %v", key, err)
}
}
}
func TestExtendedAuditReportsTransportTempAndSchemaContract(t *testing.T) {
raw := `{"database":"source_test","connection_limit":4,"tables_checked":19,"table_writes":0,"column_writes":0,"sequence_writes":0,"schema_creates":0,"database_temp":true,"ssl":false,"missing_columns":[]}`
report, err := AuditSourceSafety(context.Background(), fakeQuerier{row: fakeRow{values: []any{raw}}})
if err != nil || !report.DatabaseTemp || report.SSL || report.ConnectionLimit != 4 {
t.Fatalf("audit missing security disclosures: %+v %v", report, err)
}
raw = `{"database":"source_test","connection_limit":4,"tables_checked":19,"missing_columns":["turns.question"]}`
if _, err := AuditSourceSafety(context.Background(), fakeQuerier{row: fakeRow{values: []any{raw}}}); err == nil {
t.Fatal("missing schema contract must block source")
}
for _, raw := range []string{"{}", "not json"} {
if _, err := AuditSourceSafety(context.Background(), fakeQuerier{row: fakeRow{values: []any{raw}}}); err == nil {
t.Fatal("incomplete audit must not pass")
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package dobotaiops
import (
"path/filepath"
"strconv"
"strings"
"time"
)
// Settings contains only backend configuration. A source error must not stop
// the governance main database or its unrelated workflows from starting.
type Settings struct {
Enabled bool
TenantKey string
Connection Config
Problem string
}
func LoadSettings(getenv func(string) string, governanceDSN string) Settings {
s := Settings{}
raw := strings.TrimSpace(getenv("DOBOTAI_OPS_READ_ENABLED"))
if raw == "" {
return s
}
enabled, err := strconv.ParseBool(raw)
if err != nil {
s.Problem = "DOBOTAI_OPS_READ_ENABLED is invalid"
return s
}
if !enabled {
return s
}
s.Enabled = true
s.TenantKey = strings.TrimSpace(getenv("DOBOTAI_OPS_TENANT_KEY"))
c := Config{DSNFile: strings.TrimSpace(getenv("DOBOTAI_OPS_READ_DSN_FILE")), GovernanceDatabaseURL: governanceDSN,
DialMode: strings.TrimSpace(getenv("DOBOTAI_OPS_DIAL_MODE")), SSHTarget: strings.TrimSpace(getenv("DOBOTAI_OPS_SSH_TARGET")),
ConnectTimeout: 5 * time.Second, QueryTimeout: 4 * time.Second, MaxConns: 2, MinConns: 0}
if c.DialMode == "" {
c.DialMode = "direct"
}
for _, item := range []struct {
key string
target *time.Duration
}{
{"DOBOTAI_OPS_CONNECT_TIMEOUT", &c.ConnectTimeout}, {"DOBOTAI_OPS_QUERY_TIMEOUT", &c.QueryTimeout},
} {
if raw := getenv(item.key); raw != "" {
value, err := time.ParseDuration(raw)
if err != nil {
s.Problem = item.key + " is invalid"
return s
}
*item.target = value
}
}
for _, item := range []struct {
key string
target *int32
}{
{"DOBOTAI_OPS_MAX_CONNS", &c.MaxConns}, {"DOBOTAI_OPS_MIN_CONNS", &c.MinConns},
} {
if raw := getenv(item.key); raw != "" {
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
s.Problem = item.key + " is invalid"
return s
}
*item.target = int32(value)
}
}
if s.TenantKey == "" || len(s.TenantKey) > 128 {
s.Problem = "DOBOTAI_OPS_TENANT_KEY is required and must be at most 128 bytes"
return s
}
if !filepath.IsAbs(c.DSNFile) || filepath.Clean(c.DSNFile) != c.DSNFile {
s.Problem = "DOBOTAI_OPS_READ_DSN_FILE must be an absolute clean path"
return s
}
if err := c.Validate(); err != nil {
s.Problem = "external read configuration is invalid"
return s
}
s.Connection = c.withDefaults()
return s
}
+66
View File
@@ -0,0 +1,66 @@
package dobotaiops
import (
"errors"
"strings"
"testing"
"time"
)
func TestLoadSettingsConsumesIndependentConnectionLimits(t *testing.T) {
env := map[string]string{
"DOBOTAI_OPS_READ_ENABLED": "true", "DOBOTAI_OPS_READ_DSN_FILE": "/run/secrets/source",
"DOBOTAI_OPS_TENANT_KEY": "approved-tenant", "DOBOTAI_OPS_DIAL_MODE": "ssh", "DOBOTAI_OPS_SSH_TARGET": "approved-hop",
"DOBOTAI_OPS_CONNECT_TIMEOUT": "7s", "DOBOTAI_OPS_QUERY_TIMEOUT": "3s",
"DOBOTAI_OPS_MAX_CONNS": "3", "DOBOTAI_OPS_MIN_CONNS": "1",
}
s := LoadSettings(func(k string) string { return env[k] }, "postgres://governance@elsewhere/governance")
if !s.Enabled || s.Problem != "" || s.TenantKey != "approved-tenant" {
t.Fatalf("settings were not loaded: %+v", s)
}
if s.Connection.ConnectTimeout != 7*time.Second || s.Connection.QueryTimeout != 3*time.Second || s.Connection.MaxConns != 3 || s.Connection.MinConns != 1 {
t.Fatalf("environment overrides ignored: %+v", s.Connection)
}
}
func TestLoadSettingsIsDisabledByDefaultAndContainsInvalidSourceConfiguration(t *testing.T) {
if s := LoadSettings(func(string) string { return "" }, ""); s.Enabled || s.Problem != "" {
t.Fatalf("not disabled by default: %+v", s)
}
for _, key := range []string{"DOBOTAI_OPS_READ_ENABLED", "DOBOTAI_OPS_CONNECT_TIMEOUT", "DOBOTAI_OPS_QUERY_TIMEOUT", "DOBOTAI_OPS_MAX_CONNS", "DOBOTAI_OPS_MIN_CONNS"} {
env := map[string]string{"DOBOTAI_OPS_READ_ENABLED": "true", "DOBOTAI_OPS_READ_DSN_FILE": "/run/secrets/source", "DOBOTAI_OPS_TENANT_KEY": "approved-tenant"}
env[key] = "invalid-sensitive-value"
s := LoadSettings(func(k string) string { return env[k] }, "postgres://governance@elsewhere/governance")
if s.Problem == "" || strings.Contains(s.Problem, "invalid-sensitive-value") {
t.Fatalf("%s must fail closed without echoing its value: %+v", key, s)
}
}
s := LoadSettings(func(k string) string {
if k == "DOBOTAI_OPS_READ_ENABLED" {
return "true"
}
return ""
}, "")
if s.Problem == "" {
t.Fatal("missing secret and tenant must block source access")
}
}
func TestSecretReaderDoesNotSelectAnUnrelatedDatabaseURL(t *testing.T) {
unrelated := testDSN("writer", "not"+"a-secret", "other", "other_db")
wanted := testDSN("reader", "fixture"+"only", "source", "source_db")
got, ok := parseDSNLine("DATABASE_URL=" + unrelated + "\nDOBOTAI_OPS_READ_DSN=" + wanted)
if !ok || got != wanted {
t.Fatal("must select only the dedicated read DSN key")
}
if _, ok := parseDSNLine("DATABASE_URL=" + unrelated); ok {
t.Fatal("must not borrow another application's credential")
}
}
func TestMalformedGovernanceDSNDoesNotDisableIdentityGuard(t *testing.T) {
err := GuardGovernanceDatabase("postgres://reader@source/dobotai_prod", "invalid-main-dsn")
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("malformed main identity must fail closed: %v", err)
}
}
+133
View File
@@ -0,0 +1,133 @@
package dobotaiops
import (
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
// SSHDialer 复用既有的 SSH direct-tcpip 能力:`ssh -W <host>:<port> <target>` ——
// 由 SSH 侧完成到目标库的 TCP 连接,本机**不新增监听、不改 SSH/NPS/systemd/Docker 网络**。
//
// 实现用 socketpair:Go 侧拿到的是 fd 支撑的真实 net.Conn(支持 SetDeadline,
// pgx 的连接/查询超时才能在拨号阶段生效),ssh 子进程的 stdin/stdout 指向同一对端的另一端。
//
// context 语义(刻意区分两段):
// - **拨号阶段**尊重 ctx:ctx 已取消就不再创建连接、不再启动子进程;
// - **连接建立之后不绑定这个 ctx** —— pgx 在连接成功后就会取消拨号 ctx,
// 若把连接生命周期挂上去,健康连接会被立刻掐断。连接生命周期绑定在
// "连接关闭 ⇄ 子进程回收" 这对关系上(见 tunnelConn.Close)。
//
// 依赖:运行环境里必须有 ssh 客户端与已批准的密钥/known_hosts。容器内 localhost 不等于
// 宿主机 —— 容器没有 ssh 能力时返回 ErrUnavailable;**不擅自复制私钥、不放宽 known_hosts、
// 不改 network_mode**。
// dialAddress 是**配置里 DSN 的主机:端口**。为什么必须用它、而不是 pgx 传进来的 addr:
// pgx 会先在**本机**解析域名,本机若开了 Clash/mihomo 之类的 Fake-IP(198.18.0.0/15)或
// 其他 DNS 劫持,解析结果是假地址;把假地址交给 ssh 去连必然挂住(实测:20s 超时)。
// 固定审批的隧道应当让**远端**解析并连接配置里的主机名 —— 这也是"不接受任意主机"的同一原则。
func SSHDialer(target, dialAddress string) (func(ctx context.Context, network, addr string) (net.Conn, error), error) {
if target == "" {
return nil, fmt.Errorf("%w: ssh target is required", ErrUnavailable)
}
if strings.TrimSpace(dialAddress) == "" {
return nil, fmt.Errorf("%w: dial address is required", ErrUnavailable)
}
if _, err := exec.LookPath("ssh"); err != nil {
return nil, fmt.Errorf("%w: ssh client is not available in this environment", ErrUnavailable)
}
return func(ctx context.Context, _ string, _ string) (net.Conn, error) {
addr := dialAddress
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("%w: ssh dial cancelled before start", ErrUnavailable)
}
fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
if err != nil {
return nil, fmt.Errorf("%w: cannot create a socketpair for the ssh tunnel", ErrUnavailable)
}
localEnd := os.NewFile(uintptr(fds[0]), "kg-ssh-tunnel-local")
remoteEnd := os.NewFile(uintptr(fds[1]), "kg-ssh-tunnel-child")
conn, err := net.FileConn(localEnd)
_ = localEnd.Close()
if err != nil {
_ = remoteEnd.Close()
return nil, fmt.Errorf("%w: cannot wrap the ssh tunnel in a connection", ErrUnavailable)
}
command := exec.Command("ssh",
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=15",
"-W", addr, target)
command.Stdin = remoteEnd
command.Stdout = remoteEnd
// ssh 自身的 stderr 一律丢弃:里面可能带主机名/路径;失败统一上抛 ErrUnavailable。
command.Stderr = io.Discard
if err := command.Start(); err != nil {
_ = remoteEnd.Close()
_ = conn.Close()
return nil, fmt.Errorf("%w: cannot start the ssh tunnel", ErrUnavailable)
}
_ = remoteEnd.Close()
// reaped 必须先初始化:reap() 会在子进程退出时 close 它(nil channel 会 panic)。
tunnel := &tunnelConn{Conn: conn, command: command, reaped: make(chan struct{})}
go tunnel.reap()
return tunnel, nil
}, nil
}
// tunnelConn 把"连接关闭 ⇄ 子进程回收"显式绑定起来:
// - 连接被关(pgx 归还/释放连接、或对端断开)⇒ 子进程收到终止信号并回收,不留孤儿;
// - 子进程自己退出(隧道断/认证失败)⇒ Go 侧连接立刻关掉,让 pgx 马上感知断开;
// - Close 幂等,且不阻塞在等待子进程上(最多等一个短宽限期)。
type tunnelConn struct {
net.Conn
command *exec.Cmd
closeOnce sync.Once
reaped chan struct{}
}
func (t *tunnelConn) reap() {
if t.command != nil {
_ = t.command.Wait()
}
if t.reaped != nil {
close(t.reaped)
}
// 子进程退出 ⇒ 连接必然无用,主动关掉让调用方立刻看到 EOF。
_ = t.Conn.Close()
}
func (t *tunnelConn) Close() error {
var err error
t.closeOnce.Do(func() {
err = t.Conn.Close()
// 连接关闭后 ssh 通常自行退出;这里补一刀保证它不会挂着不退。
if t.command.Process != nil {
_ = t.command.Process.Signal(syscall.SIGTERM)
}
select {
case <-t.Exited():
case <-time.After(2 * time.Second):
if t.command.Process != nil {
_ = t.command.Process.Kill()
}
}
})
return err
}
// Exited 让测试/运维能确认子进程已经回收(不暴露任何连接信息)。
func (t *tunnelConn) Exited() <-chan struct{} {
if t.reaped == nil {
closed := make(chan struct{})
close(closed)
return closed
}
return t.reaped
}
+104
View File
@@ -0,0 +1,104 @@
package dobotaiops
import (
"bufio"
"context"
"fmt"
"net"
"net/http"
"os"
"strings"
"testing"
"time"
)
// 只有在显式给出"已批准的 SSH 目标 + 一个可达的只读端点"时才跑:CI 默认跳过。
// 目的:证明 `ssh -W` + socketpair 的拨号在 Go 侧是一个**支持 deadline 的真实 net.Conn**
// (pgx 的连接/查询超时能在拨号阶段生效),且经这条通道发出的请求真的能到达目标。
//
// KG_SSH_DIAL_TARGET=weknora-prod KG_SSH_DIAL_ADDR=127.0.0.1:8090 // go test ./internal/dobotaiops/ -run TestSSHDialerTransportsRealTraffic -v
func TestSSHDialerTransportsRealTraffic(t *testing.T) {
target := strings.TrimSpace(os.Getenv("KG_SSH_DIAL_TARGET"))
address := strings.TrimSpace(os.Getenv("KG_SSH_DIAL_ADDR"))
if target == "" || address == "" {
t.Skip("KG_SSH_DIAL_TARGET / KG_SSH_DIAL_ADDR 未设置:跳过(CI 默认不跑)")
}
dialer, err := SSHDialer(target, address)
if err != nil {
t.Fatalf("SSHDialer() error = %v", err)
}
conn, err := dialer(context.Background(), "tcp", address)
if err != nil {
t.Fatalf("dial error = %v", err)
}
defer conn.Close()
// 真实 deadline 必须可用(否则 pgx 的连接超时形同虚设)。
if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
t.Fatalf("socketpair 连接必须支持 deadline:%v", err)
}
if _, err := fmt.Fprintf(conn, "GET /version HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", address); err != nil {
t.Fatalf("write error = %v", err)
}
response, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read error = %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d", response.StatusCode)
}
buf := make([]byte, 256)
n, _ := response.Body.Read(buf)
if !strings.Contains(string(buf[:n]), "commit_sha") {
t.Fatalf("经 SSH 通道取到的响应不像版本接口:%q", string(buf[:n]))
}
var _ net.Conn = conn
}
// 取消的 context 不得创建连接、不得启动子进程(独立探测曾证明旧实现照样建连)。
func TestSSHDialerRespectsCancelledContext(t *testing.T) {
dialer, err := SSHDialer("definitely-not-a-real-ssh-alias", "127.0.0.1:5432")
if err != nil {
t.Fatalf("SSHDialer() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
conn, err := dialer(ctx, "tcp", "127.0.0.1:1")
if err == nil || conn != nil {
t.Fatalf("ctx 已取消时不得建连:conn=%v err=%v", conn, err)
}
if !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("应明确说明是取消:%v", err)
}
}
// 关闭连接必须回收子进程(不阻塞、幂等);顺带证明 GetDeadline/SetDeadline 是真实能力。
func TestSSHTunnelCloseReapsChild(t *testing.T) {
target := strings.TrimSpace(os.Getenv("KG_SSH_DIAL_TARGET"))
if target == "" {
t.Skip("KG_SSH_DIAL_TARGET 未设置:跳过(需要有已批准目标才能起 ssh 子进程)")
}
dialer, err := SSHDialer(target, "127.0.0.1:1") // 目标端口不通也没关系:本轮只看生命周期
if err != nil {
t.Fatalf("SSHDialer() error = %v", err)
}
conn, err := dialer(context.Background(), "tcp", "127.0.0.1:1")
if err != nil {
t.Skipf("无法在本机建立隧道(%v)—— 该用例只在有 ssh 能力的环境有意义", err)
}
tunnel, ok := conn.(*tunnelConn)
if !ok {
t.Fatalf("返回值应绑定子进程生命周期:%T", conn)
}
if err := tunnel.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if err := tunnel.Close(); err != nil {
t.Fatalf("Close() 必须幂等:%v", err)
}
select {
case <-tunnel.Exited():
case <-time.After(5 * time.Second):
t.Fatal("关闭连接后 ssh 子进程没有被回收")
}
}
+87
View File
@@ -0,0 +1,87 @@
package dobotaiops
import (
"encoding/base64"
"fmt"
"strings"
"time"
)
// Window 是一次只读统计/列举的时间范围,语义固定为**半开区间**:
// created_at >= Start AND created_at < End(工单要求)。
type Window struct {
TenantKey string
Start time.Time
End time.Time
}
// NewWindow 解析并校验调用方给的租户与时间范围:
// - 租户非空、长度受限(不做任何 SQL 拼接,只作为绑定参数);
// - 时间是 RFC3339(允许带时区),必须有 start < end;
// - 窗口跨度不超过上限(默认 90 天)、不允许跑到未来太远。
func NewWindow(tenantKey, startRaw, endRaw string, limit time.Duration, now time.Time) (Window, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" || len(tenantKey) > 128 {
return Window{}, fmt.Errorf("%w: tenant key is required", ErrInvalidQuery)
}
start, err := parseInstant(startRaw)
if err != nil {
return Window{}, err
}
end, err := parseInstant(endRaw)
if err != nil {
return Window{}, err
}
if !start.Before(end) {
return Window{}, fmt.Errorf("%w: start must be before end", ErrInvalidQuery)
}
if limit <= 0 {
limit = 90 * 24 * time.Hour
}
if end.Sub(start) > limit {
return Window{}, fmt.Errorf("%w: window exceeds the %s limit", ErrInvalidQuery, limit)
}
if end.After(now.Add(5 * time.Minute)) {
return Window{}, fmt.Errorf("%w: end is in the future", ErrInvalidQuery)
}
return Window{TenantKey: tenantKey, Start: start.UTC(), End: end.UTC()}, nil
}
func parseInstant(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, fmt.Errorf("%w: time is required", ErrInvalidQuery)
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, fmt.Errorf("%w: time must be RFC3339 (with offset)", ErrInvalidQuery)
}
return parsed.UTC(), nil
}
// Cursor 是 (created_at, id) 复合游标:与工单要求一致 —— 稳定、确定性、翻页不漏不重。
type Cursor struct {
CreatedAt time.Time
ID string
}
// EncodeCursor / DecodeCursor 只做无损编解码,不做业务判断;非法游标一律报 ErrInvalidQuery。
func EncodeCursor(cursor Cursor) string {
return base64.RawURLEncoding.EncodeToString([]byte(cursor.CreatedAt.UTC().Format(time.RFC3339Nano) + "|" + cursor.ID))
}
func DecodeCursor(raw string) (Cursor, error) {
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil {
return Cursor{}, fmt.Errorf("%w: cursor is not valid", ErrInvalidQuery)
}
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) != 2 || parts[1] == "" {
return Cursor{}, fmt.Errorf("%w: cursor is not valid", ErrInvalidQuery)
}
createdAt, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return Cursor{}, fmt.Errorf("%w: cursor is not valid", ErrInvalidQuery)
}
return Cursor{CreatedAt: createdAt.UTC(), ID: parts[1]}, nil
}
+41
View File
@@ -0,0 +1,41 @@
package governance
import (
"strings"
)
// Development headings cannot replace the formal release baseline.
func ExtractDevelopmentNotes(changelog, version string) string {
return developmentNotes(changelog, version, "")
}
func ExtractDevelopmentNotesForCommit(changelog, version, sha string) string {
return developmentNotes(changelog, version, sha)
}
func developmentNotes(changelog, version, sha string) string {
if !SemverValid(version) {
return ""
}
lines := strings.Split(changelog, "\n")
prefix := "## 开发快照 snapshot/v" + version + " - "
for i, line := range lines {
if strings.HasPrefix(line, prefix) {
end := len(lines)
for j := i + 1; j < len(lines); j++ {
if strings.HasPrefix(lines[j], "## ") {
end = j
break
}
}
body := strings.TrimSpace(strings.Join(lines[i+1:end], "\n"))
if sha == "" {
return body
}
for _, field := range lines[i+1 : end] {
if strings.TrimSpace(field) == "目标提交:"+sha {
return body
}
}
}
}
return ""
}
+1 -1
View File
@@ -101,7 +101,7 @@ func isAuthEndpoint(method, path string) bool {
func classifyRole(method, path string) string {
// The member roster exposes account identities and roles: admin-only for
// every method, checked before the read shortcut below.
if strings.HasPrefix(path, "/api/v1/users") {
if strings.HasPrefix(path, "/api/v1/users") || strings.HasPrefix(path, "/api/v1/qa-ops/") {
return roleAdmin
}
if method == http.MethodGet {
+1 -1
View File
@@ -77,7 +77,7 @@ func TestOpenAPICatalogMutationsRequireIdempotencyKeyHeader(t *testing.T) {
if !ok || schema["type"] != "string" || schema["minLength"] != float64(1) || schema["maxLength"] != float64(128) {
t.Fatalf("IdempotencyKey header schema is unsafe: %#v", parameter["schema"])
}
for _, path := range []string{"/api/v1/source-catalog/route", "/api/v1/source-catalog/sources/{id}/refresh"} {
for _, path := range []string{"/api/v1/qa-ops/organization/sync", "/api/v1/source-catalog/route", "/api/v1/source-catalog/sources/{id}/refresh"} {
operation, ok := document.Paths[path]["post"].(map[string]any)
if !ok {
t.Fatalf("missing POST %s", path)
+185
View File
@@ -0,0 +1,185 @@
package httpapi
import (
"context"
"errors"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"net/http"
"strings"
"time"
)
var errQAOrganization = errors.New("organization attribution unavailable")
type qaAttributionProvider interface {
GetAttribution(context.Context) (*qaopsorg.Attribution, error)
}
func recordAttribution(ctx context.Context, config Config) (*qaopsorg.Attribution, error) {
if config.QAOrg == nil {
return qaopsorg.BuildAttribution(qaopsorg.State{State: "empty"}, qaopsorg.Directory{})
}
provider, ok := config.QAOrg.(qaAttributionProvider)
if !ok {
return nil, errQAOrganization
}
p, err := provider.GetAttribution(ctx)
if err != nil || p == nil {
return nil, errQAOrganization
}
return p, nil
}
func attributionVersion(p *qaopsorg.Attribution) dobotaiops.OrganizationVersion {
return dobotaiops.OrganizationVersion{State: p.Metadata.State, DirectoryID: p.Metadata.DirectoryID, DirectorySource: p.Metadata.DirectorySource, SyncedAt: p.Metadata.DirectorySyncedAt, RevisionID: p.Metadata.RevisionID, Basis: "current_directory"}
}
func enrichQARecord(item *dobotaiops.Record, p *qaopsorg.Attribution) {
person := p.Resolve(item.Identity.StaffID, item.Identity.UnionID)
o := &dobotaiops.RecordOrganization{Version: attributionVersion(p), State: person.State, Departments: person.Departments, Region: person.Region, SourceDepartment: item.Department, SourceRegion: item.Region, DepartmentOrigin: "unavailable", RegionOrigin: "unavailable"}
if item.Department != "" {
o.DepartmentOrigin = "source_snapshot"
} else if len(person.Departments) > 0 {
item.Department = strings.Join(person.Departments, ";")
o.DepartmentOrigin = "current_directory"
}
if item.Region != "" {
o.RegionOrigin = "source_snapshot"
} else if person.Region != "" {
item.Region = person.Region
o.RegionOrigin = "current_directory"
}
item.Organization = o
}
func enrichQAPage(page *dobotaiops.RecordPage, p *qaopsorg.Attribution) {
version := attributionVersion(p)
page.Organization = &version
for i := range page.Items {
enrichQARecord(&page.Items[i], p)
}
for _, actor := range page.ScopeActors {
person := p.Resolve(actor.StaffID, actor.UnionID)
if len(person.Departments) > 0 {
dobotaiops.AddRecordColumns(page, "department")
}
if person.Region != "" {
dobotaiops.AddRecordColumns(page, "region")
}
}
}
type qaUsageReader interface {
ReadUsageGroups(context.Context, dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error)
}
type qaUsageRow struct {
Key string `json:"key"`
Questions int64 `json:"questions"`
Succeeded int64 `json:"succeeded"`
Failed int64 `json:"failed"`
Other int64 `json:"other"`
Users *int `json:"users"`
RosterPeople *int `json:"roster_people"`
}
type qaUsage struct {
Organization dobotaiops.OrganizationVersion `json:"organization"`
AsOf time.Time `json:"as_of"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Keyword string `json:"keyword"`
Questions int64 `json:"questions"`
IdentifiedUsers int `json:"identified_users"`
Rows []qaUsageRow `json:"rows"`
}
func summarizeQAUsage(groups dobotaiops.UsageGroups, p *qaopsorg.Attribution, query dobotaiops.RecordQuery) qaUsage {
result := qaUsage{Organization: attributionVersion(p), AsOf: groups.AsOf, Start: query.Window.Start, End: query.Window.End, Keyword: query.Keyword, Rows: []qaUsageRow{}}
keys := append(append([]string{}, qaopsorg.RegionNames...), "unassigned", "cross_region", "unmatched", "identity_conflict", "no_identity", "directory_missing")
rows := map[string]*qaUsageRow{}
members := map[string]map[string]bool{}
all := map[string]bool{}
for _, key := range keys {
row := &qaUsageRow{Key: key}
if p.Metadata.State == "ready" {
var n int
valid := true
switch key {
case "unassigned":
n = p.Metadata.View.UnassignedPeople
case "cross_region":
n = p.Metadata.View.ConflictingPeople
default:
var ok bool
n, ok = p.Metadata.View.Regions[key]
valid = ok
}
if valid {
row.RosterPeople = &n
row.Users = new(int)
}
}
rows[key] = row
members[key] = map[string]bool{}
}
for _, group := range groups.Groups {
person := p.Resolve(group.Identity.StaffID, group.Identity.UnionID)
key := person.State
if person.Region != "" {
key = person.Region
}
row := rows[key]
if row == nil {
continue
}
row.Questions += group.Total
row.Succeeded += group.Succeeded
row.Failed += group.Failed
row.Other += group.Total - group.Succeeded - group.Failed
result.Questions += group.Total
if person.PersonID != "" {
members[key][person.PersonID] = true
all[person.PersonID] = true
}
}
for _, key := range keys {
row := rows[key]
if row.Users != nil {
*row.Users = len(members[key])
}
result.Rows = append(result.Rows, *row)
}
result.IdentifiedUsers = len(all)
return result
}
func qaOpsUsage(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
reader, ok := config.QAOps.(qaUsageReader)
if !ok || config.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
values := r.URL.Query()
for key, items := range values {
if (key != "start" && key != "end" && key != "keyword") || len(items) != 1 {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
}
window, err := dobotaiops.NewWindow(config.QAOpsTenantKey, values.Get("start"), values.Get("end"), 90*24*time.Hour, config.Now())
if err != nil {
qaOpsError(w, err)
return
}
p, err := recordAttribution(r.Context(), config)
if err != nil {
qaOpsError(w, err)
return
}
query := dobotaiops.RecordQuery{Window: window, Keyword: values.Get("keyword")}
groups, err := reader.ReadUsageGroups(r.Context(), query)
if err != nil {
qaOpsError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": summarizeQAUsage(groups, p, query)})
}
+130
View File
@@ -0,0 +1,130 @@
package httpapi
import (
"context"
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type attributionOrgSpy struct {
orgSpy
projection *qaopsorg.Attribution
}
func (s *attributionOrgSpy) GetAttribution(context.Context) (*qaopsorg.Attribution, error) {
return s.projection, s.err
}
type attributedReader struct{}
func (*attributedReader) Available(context.Context) error { return nil }
func (*attributedReader) Safety() dobotaiops.SourceSafety { return dobotaiops.SourceSafety{SSL: true} }
func (*attributedReader) ListRecords(context.Context, dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
return dobotaiops.RecordPage{Items: []dobotaiops.Record{{ID: "anonymous"}}, ScopeActors: []dobotaiops.ActorIdentity{{StaffID: "fixture-private-staff"}}, Columns: []string{"question"}, Total: 3}, nil
}
func (*attributedReader) GetRecord(context.Context, string, string) (dobotaiops.Record, error) {
return dobotaiops.Record{ID: "one", Department: "历史部门", Region: "历史区域", Identity: dobotaiops.ActorIdentity{StaffID: "fixture-private-staff"}}, nil
}
func (*attributedReader) ReadUsageGroups(context.Context, dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error) {
return dobotaiops.UsageGroups{AsOf: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC), Groups: []dobotaiops.UsageGroup{{Identity: dobotaiops.ActorIdentity{StaffID: "fixture-private-staff"}, Total: 3, Succeeded: 2, Failed: 1}, {Identity: dobotaiops.ActorIdentity{UnionID: "fixture-private-union"}, Total: 2, Succeeded: 2}, {Identity: dobotaiops.ActorIdentity{StaffID: "unmatched"}, Total: 2}}}, nil
}
func attributionHandler(t *testing.T) (http.Handler, *attributionOrgSpy) {
t.Helper()
projection, err := qaopsorg.BuildAttribution(qaopsorg.State{State: "ready", DirectoryID: "directory-v1", RevisionID: "region-v1", Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "APAC"}}}, qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "测试公司"}}, People: []qaopsorg.Person{{ID: "fixture-private-staff", UnionID: "fixture-private-union", DepartmentIDs: []string{"root"}}}})
if err != nil {
t.Fatal(err)
}
org := &attributionOrgSpy{projection: projection}
auth := newFakeAuthStore()
auth.sessions["attribution-admin"] = "usr-boss"
auth.sessions["attribution-viewer"] = "usr-eye"
h, err := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: &attributedReader{}, QAOpsTenantKey: "fixed", QAOrg: org, Now: func() time.Time { return time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) }})
if err != nil {
t.Fatal(err)
}
return h, org
}
func attributionRequest(h http.Handler, path, token string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
const attributionFilter = "?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z"
func TestQAAttributionUsesWholeFilterAndPreservesHistoricalSnapshots(t *testing.T) {
h, org := attributionHandler(t)
w := attributionRequest(h, "/api/v1/qa-ops/records"+attributionFilter, "attribution-admin")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var page struct{ Data dobotaiops.RecordPage }
_ = json.Unmarshal(w.Body.Bytes(), &page)
if !strings.Contains(strings.Join(page.Data.Columns, ","), "department,region") {
t.Fatal("organization column hidden because first page is anonymous", w.Body.String())
}
w = attributionRequest(h, "/api/v1/qa-ops/records/one", "attribution-admin")
var detail struct{ Data dobotaiops.Record }
_ = json.Unmarshal(w.Body.Bytes(), &detail)
if detail.Data.Department != "历史部门" || detail.Data.Region != "历史区域" || detail.Data.Organization == nil || detail.Data.Organization.Region != "APAC" {
t.Fatal("source snapshot overwritten or current attribution missing", w.Body.String())
}
if strings.Contains(w.Body.String(), "fixture-private") {
t.Fatal("identity keys exposed")
}
org.err = qaopsorg.ErrUnavailable
w = attributionRequest(h, "/api/v1/qa-ops/records"+attributionFilter, "attribution-admin")
if w.Code != 503 || !strings.Contains(w.Body.String(), "organization_unavailable") {
t.Fatal("permission/storage failure hidden as empty", w.Body.String())
}
}
func TestQAUsageIsAdminScopedDeduplicatedAndNotPerformance(t *testing.T) {
h, _ := attributionHandler(t)
path := "/api/v1/qa-ops/usage" + attributionFilter
for _, tc := range []struct {
token string
status int
}{{"", 401}, {"attribution-viewer", 403}, {"attribution-admin", 200}} {
w := attributionRequest(h, path, tc.token)
if w.Code != tc.status {
t.Fatalf("usage guard got %d: %s", w.Code, w.Body.String())
}
if tc.status == 200 {
var result struct {
Data struct {
Questions int64 `json:"questions"`
IdentifiedUsers int `json:"identified_users"`
Rows []struct {
Key string
Questions int64
Users *int
}
}
}
if json.Unmarshal(w.Body.Bytes(), &result) != nil || result.Data.Questions != 7 || result.Data.IdentifiedUsers != 1 {
t.Fatal("events/people not deduplicated", w.Body.String())
}
for _, row := range result.Data.Rows {
if row.Key == "APAC" && (row.Questions != 5 || row.Users == nil || *row.Users != 1) {
t.Fatal("regional user double-counted")
}
}
if strings.Contains(w.Body.String(), "fixture-private") || strings.Contains(w.Body.String(), "performance") {
t.Fatal("private identity or performance verdict leaked")
}
}
}
for _, extra := range []string{"&tenant=forged", "&limit=1", "&cursor=forged", "&start=duplicate"} {
if w := attributionRequest(h, path+extra, "attribution-admin"); w.Code != 400 {
t.Fatal("usage accepted client scope or pagination")
}
}
}
+312
View File
@@ -0,0 +1,312 @@
package httpapi
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
_ "time/tzdata"
"unicode/utf8"
)
type monthlyFilter struct {
Month, Region, Department, Activity, Search string
Start, End, MonthEnd time.Time
Page, Limit int
}
type monthlyPerson struct {
Key string `json:"key"`
Name string `json:"name"`
State string `json:"state"`
Region string `json:"region"`
Departments []string `json:"departments"`
Questions int64 `json:"questions"`
Succeeded int64 `json:"succeeded"`
Failed int64 `json:"failed"`
Other int64 `json:"other"`
}
type monthlyDepartment struct {
ID string `json:"id"`
Path string `json:"path"`
}
type monthlyPeople struct {
Organization dobotaiops.OrganizationVersion `json:"organization"`
Source string `json:"source"`
Timezone string `json:"timezone"`
Month string `json:"month"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
MonthEnd time.Time `json:"month_end"`
AsOf time.Time `json:"as_of"`
Partial bool `json:"partial"`
Questions int64 `json:"questions"`
LinkedQuestions int64 `json:"linked_questions"`
UnresolvedQuestions int64 `json:"unresolved_questions"`
RosterPeople int `json:"roster_people"`
UsedPeople int `json:"used_people"`
ZeroPeople int `json:"zero_people"`
MissingNames int `json:"missing_names"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
HasMore bool `json:"has_more"`
Rows []monthlyPerson `json:"rows"`
DepartmentOptions []monthlyDepartment `json:"department_options"`
}
func parseMonthlyFilter(values url.Values, now time.Time) (monthlyFilter, error) {
f := monthlyFilter{Month: values.Get("month"), Region: values.Get("region"), Department: values.Get("department_id"), Activity: values.Get("activity"), Search: values.Get("search"), Page: 1, Limit: 30}
allowed := map[string]bool{"month": true, "cutoff": true, "region": true, "department_id": true, "activity": true, "search": true, "page": true, "limit": true, "directory_id": true, "revision_id": true}
for key, v := range values {
if !allowed[key] || len(v) != 1 {
return f, dobotaiops.ErrInvalidQuery
}
}
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return f, dobotaiops.ErrUnavailable
}
f.Start, err = time.ParseInLocation("2006-01", f.Month, loc)
if err != nil || len(f.Month) != 7 || f.Start.Year() < 1 || f.Start.Format("2006-01") != f.Month || !f.Start.Before(now) {
return f, dobotaiops.ErrInvalidQuery
}
f.MonthEnd = f.Start.AddDate(0, 1, 0)
f.End = f.MonthEnd
if now.Before(f.End) {
f.End = now.In(loc)
}
if raw := values.Get("cutoff"); raw != "" {
cutoff, e := time.Parse(time.RFC3339Nano, raw)
if e != nil || !cutoff.After(f.Start) || cutoff.After(f.MonthEnd) || cutoff.After(now) {
return f, dobotaiops.ErrInvalidQuery
}
f.End = cutoff.In(loc)
}
if f.Activity == "" {
f.Activity = "all"
}
if f.Activity != "all" && f.Activity != "used" && f.Activity != "zero" {
return f, dobotaiops.ErrInvalidQuery
}
validRegion := f.Region == "" || f.Region == "unassigned" || f.Region == "cross_region"
for _, r := range qaopsorg.RegionNames {
validRegion = validRegion || f.Region == r
}
if !validRegion {
return f, dobotaiops.ErrInvalidQuery
}
if !utf8.ValidString(f.Search) || utf8.RuneCountInString(f.Search) > 100 || strings.ContainsRune(f.Search, 0) {
return f, dobotaiops.ErrInvalidQuery
}
for key, target := range map[string]*int{"page": &f.Page, "limit": &f.Limit} {
if raw := values.Get(key); raw != "" {
n, e := strconv.Atoi(raw)
if e != nil || n < 1 || n > 100000 {
return f, dobotaiops.ErrInvalidQuery
}
*target = n
}
}
if f.Limit > 100 || f.Page > 1 && (!values.Has("directory_id") || !values.Has("revision_id")) {
return f, dobotaiops.ErrInvalidQuery
}
return f, nil
}
func monthlyHasDepartment(person qaopsorg.PersonAttribution, department string, parents map[string]string) bool {
if department == "" {
return true
}
for _, id := range person.DepartmentIDs {
for cur := id; cur != ""; cur = parents[cur] {
if cur == department {
return true
}
}
}
return false
}
func monthlyResult(groups dobotaiops.UsageGroups, p *qaopsorg.Attribution, f monthlyFilter) monthlyPeople {
result := monthlyPeople{Organization: attributionVersion(p), Source: "2.0", Timezone: "Asia/Shanghai", Month: f.Month, Start: f.Start, End: f.End, MonthEnd: f.MonthEnd, AsOf: groups.AsOf, Partial: f.End.Before(f.MonthEnd), Page: f.Page, Limit: f.Limit, Rows: []monthlyPerson{}, DepartmentOptions: []monthlyDepartment{}}
totals := map[string]dobotaiops.UsageGroup{}
for _, group := range groups.Groups {
result.Questions += group.Total
person := p.Resolve(group.Identity.StaffID, group.Identity.UnionID)
if person.PersonID == "" {
result.UnresolvedQuestions += group.Total
continue
}
n := totals[person.PersonID]
n.Total += group.Total
n.Succeeded += group.Succeeded
n.Failed += group.Failed
totals[person.PersonID] = n
result.LinkedQuestions += group.Total
}
parents := map[string]string{}
for _, d := range p.Metadata.View.Departments {
parents[d.ID] = d.ParentID
result.DepartmentOptions = append(result.DepartmentOptions, monthlyDepartment{ID: d.ID, Path: d.Path})
}
rows := []monthlyPerson{}
people := p.Members()
result.RosterPeople = len(people)
search := strings.ToLower(f.Search)
for _, person := range people {
n := totals[person.PersonID]
if n.Total > 0 {
result.UsedPeople++
} else {
result.ZeroPeople++
}
if person.Name == "" {
result.MissingNames++
}
if f.Region != "" && f.Region != person.Region && f.Region != person.State {
continue
}
if !monthlyHasDepartment(person, f.Department, parents) {
continue
}
if f.Activity == "used" && n.Total == 0 || f.Activity == "zero" && n.Total > 0 {
continue
}
if search != "" && !strings.Contains(strings.ToLower(person.Name), search) && !strings.Contains(strings.ToLower(strings.Join(person.Departments, ";")), search) {
continue
}
// This is a stable row key, not an anonymisation claim or a new user identifier.
sum := sha256.Sum256([]byte(p.Metadata.DirectoryID + "\x00" + person.PersonID))
rows = append(rows, monthlyPerson{Key: "person-" + hex.EncodeToString(sum[:]), Name: person.Name, State: person.State, Region: person.Region, Departments: person.Departments, Questions: n.Total, Succeeded: n.Succeeded, Failed: n.Failed, Other: n.Total - n.Succeeded - n.Failed})
}
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
if a.Name == "" && b.Name != "" {
return false
}
if a.Name != "" && b.Name == "" {
return true
}
if a.Name != b.Name {
return a.Name < b.Name
}
return a.Key < b.Key
})
result.Total = len(rows)
start := (f.Page - 1) * f.Limit
if start < len(rows) {
end := min(start+f.Limit, len(rows))
result.Rows = rows[start:end]
result.HasMore = end < len(rows)
}
return result
}
// Names remain in the bounded read-query body, not URLs/access logs. The source
// reader and governance store are still strictly read-only on this route.
func monthlyRequestValues(w http.ResponseWriter, r *http.Request) (url.Values, error) {
values := url.Values{}
if r.URL.RawQuery != "" || r.Header.Get("X-Governance-Action") != "qa-usage-read" {
return nil, dobotaiops.ErrInvalidQuery
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
token, err := decoder.Token()
if err != nil || token != json.Delim('{') {
return nil, dobotaiops.ErrInvalidQuery
}
for decoder.More() {
token, err = decoder.Token()
key, ok := token.(string)
if err != nil || !ok || values.Has(key) {
return nil, dobotaiops.ErrInvalidQuery
}
var raw json.RawMessage
if decoder.Decode(&raw) != nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, dobotaiops.ErrInvalidQuery
}
if key == "page" || key == "limit" {
var n int
if json.Unmarshal(raw, &n) != nil {
return nil, dobotaiops.ErrInvalidQuery
}
values.Set(key, strconv.Itoa(n))
} else {
var text string
if json.Unmarshal(raw, &text) != nil {
return nil, dobotaiops.ErrInvalidQuery
}
values.Set(key, text)
}
}
if _, err = decoder.Token(); err != nil {
return nil, dobotaiops.ErrInvalidQuery
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return nil, dobotaiops.ErrInvalidQuery
}
return values, nil
}
func qaMonthlyPeople(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
values, err := monthlyRequestValues(w, r)
if err != nil {
qaOpsError(w, err)
return
}
f, err := parseMonthlyFilter(values, config.Now())
if err != nil {
qaOpsError(w, err)
return
}
p, err := recordAttribution(r.Context(), config)
if err != nil {
qaOpsError(w, err)
return
}
if p.Metadata.State != "ready" {
writeJSON(w, 409, map[string]string{"code": "directory_missing", "error": "尚无完整通讯录,请先从区域使用页同步;不能生成零记录人员名单"})
return
}
if values.Has("directory_id") || values.Has("revision_id") {
if !values.Has("directory_id") || !values.Has("revision_id") {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
if values.Get("directory_id") != p.Metadata.DirectoryID || values.Get("revision_id") != p.Metadata.RevisionID {
writeJSON(w, 409, map[string]string{"code": "organization_changed", "error": "通讯录或区域配置已变化,请重新查询个人月明细"})
return
}
}
if f.Department != "" {
found := false
for _, d := range p.Metadata.View.Departments {
found = found || d.ID == f.Department
}
if !found {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
}
reader, ok := config.QAOps.(qaUsageReader)
if !ok || config.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
groups, err := reader.ReadUsageGroups(r.Context(), dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: config.QAOpsTenantKey, Start: f.Start, End: f.End}})
if err != nil {
qaOpsError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": monthlyResult(groups, p, f)})
}
+221
View File
@@ -0,0 +1,221 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/http/httptest"
"strconv"
"net/url"
"strings"
"testing"
"time"
)
type monthlyReaderSpy struct {
attributedReader
query dobotaiops.RecordQuery
calls int
}
func (s *monthlyReaderSpy) ReadUsageGroups(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error) {
s.calls++
s.query = q
return s.attributedReader.ReadUsageGroups(ctx, q)
}
func monthlyRequest(h http.Handler, path, token string) *httptest.ResponseRecorder {
u, _ := url.Parse(path)
body := map[string]any{}
for k, v := range u.Query() {
if len(v) != 1 {
body[k] = v
continue
}
if k == "page" || k == "limit" {
if n, e := strconv.Atoi(v[0]); e == nil {
body[k] = n
} else {
body[k] = v[0]
}
} else {
body[k] = v[0]
}
}
raw, _ := json.Marshal(body)
r := httptest.NewRequest(http.MethodPost, u.Path, bytes.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Governance-Action", "qa-usage-read")
if token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func monthlyHandler(t *testing.T) (http.Handler, *attributionOrgSpy, *monthlyReaderSpy) {
t.Helper()
d := qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "示例根"}, {ID: "child", ParentID: "root", Name: "子部门"}}, People: []qaopsorg.Person{{ID: "fixture-private-staff", Name: "同名示例", UnionID: "fixture-private-union", DepartmentIDs: []string{"root"}}, {ID: "private-zero", Name: "同名示例", DepartmentIDs: []string{"child"}}, {ID: "private-cross", Name: "跨区域示例", DepartmentIDs: []string{"root", "child"}}, {ID: "private-unnamed", DepartmentIDs: []string{"child"}}}}
now := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
p, err := qaopsorg.BuildAttribution(qaopsorg.State{State: "ready", DirectoryID: "monthly-directory", DirectorySource: "weekly_report_live", DirectorySyncedAt: &now, RevisionID: "monthly-regions", Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "国内工业"}, {DepartmentID: "child", Region: "APAC"}}}, d)
if err != nil {
t.Fatal(err)
}
org := &attributionOrgSpy{projection: p}
reader := &monthlyReaderSpy{}
auth := newFakeAuthStore()
auth.sessions["attribution-admin"] = "usr-boss"
auth.sessions["attribution-viewer"] = "usr-eye"
h, err := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: reader, QAOpsTenantKey: "fixed", QAOrg: org, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return h, org, reader
}
func TestQAMonthlyPeopleIncludesZeroUseAndKeepsUnresolvedEvents(t *testing.T) {
h, _, reader := monthlyHandler(t)
path := "/api/v1/qa-ops/usage/people?month=2026-10&limit=2"
w := monthlyRequest(h, path, "attribution-admin")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var result struct {
Data struct {
Month string
Timezone string
Partial bool
Questions int64
LinkedQuestions int64 `json:"linked_questions"`
UnresolvedQuestions int64 `json:"unresolved_questions"`
RosterPeople int `json:"roster_people"`
UsedPeople int `json:"used_people"`
ZeroPeople int `json:"zero_people"`
MissingNames int `json:"missing_names"`
Total int
HasMore bool `json:"has_more"`
Rows []struct {
Key string
Name string
Questions int64
State string
}
}
}
if json.Unmarshal(w.Body.Bytes(), &result) != nil {
t.Fatal("invalid monthly JSON")
}
v := result.Data
if v.Month != "2026-10" || v.Timezone != "Asia/Shanghai" || !v.Partial || v.Questions != 7 || v.LinkedQuestions != 5 || v.UnresolvedQuestions != 2 || v.RosterPeople != 4 || v.UsedPeople != 1 || v.ZeroPeople != 3 || v.MissingNames != 1 || v.Total != 4 || len(v.Rows) != 2 || !v.HasMore {
t.Fatal("monthly roster, zero count or alias dedup wrong", w.Body.String())
}
if reader.query.Window.TenantKey != "fixed" || reader.query.Window.Start.Format(time.RFC3339) != "2026-10-01T00:00:00+08:00" || reader.query.Window.End.Format(time.RFC3339) != "2026-10-17T16:00:00+08:00" {
t.Fatal("month was not normalized to the named timezone")
}
if strings.Contains(w.Body.String(), "private-") || strings.Contains(w.Body.String(), "performance") || strings.Contains(w.Body.String(), "staff_id") {
t.Fatal("raw identities or unapproved verdict exposed")
}
page2 := monthlyRequest(h, path+"&page=2&directory_id=monthly-directory&revision_id=monthly-regions", "attribution-admin")
if page2.Code != 200 {
t.Fatal(page2.Body.String())
}
for _, extra := range []string{"&activity=zero", "&region=cross_region", "&department_id=child", "&search=" + url.QueryEscape("子部门")} {
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+extra, "attribution-admin")
if r.Code != 200 {
t.Fatal("valid monthly filter failed", r.Body.String())
}
}
}
func TestQAMonthlyScopeAndVersionFailClosed(t *testing.T) {
h, org, reader := monthlyHandler(t)
base := "/api/v1/qa-ops/usage/people?month=2026-10"
for _, tc := range []struct {
suffix, token string
status int
}{{"", "", 401}, {"", "attribution-viewer", 403}, {"&tenant=forged", "attribution-admin", 400}, {"&threshold=5", "attribution-admin", 400}, {"&month=duplicate", "attribution-admin", 400}, {"&page=0", "attribution-admin", 400}, {"&limit=101", "attribution-admin", 400}, {"&activity=under5", "attribution-admin", 400}, {"&region=invalid", "attribution-admin", 400}, {"&department_id=invalid", "attribution-admin", 400}, {"&page=2", "attribution-admin", 400}, {"&page=2&directory_id=changed&revision_id=monthly-regions", "attribution-admin", 409}, {"&cutoff=2026-11-01T00:00:00Z", "attribution-admin", 400}} {
w := monthlyRequest(h, base+tc.suffix, tc.token)
if w.Code != tc.status {
t.Fatalf("monthly %s: %d want %d", tc.suffix, w.Code, tc.status)
}
}
for _, month := range []string{"2026-1", " 2026-10", "2026-13", "0000-01", "2026-11"} {
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month="+url.QueryEscape(month), "attribution-admin")
if r.Code != 400 {
t.Fatal("invalid/future month accepted")
}
}
if reader.calls != 0 {
t.Fatal("invalid or unauthorized monthly request reached source")
}
org.projection, _ = qaopsorg.BuildAttribution(qaopsorg.State{State: "empty"}, qaopsorg.Directory{})
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 409 {
t.Fatal("no roster presented as zero-use list")
}
org.err = qaopsorg.ErrUnavailable
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 503 {
t.Fatal("store failure hidden as empty")
}
}
func TestQAMonthlyPersonFiltersDoNotGuessNamesOrDuplicateDescendantMembers(t *testing.T) {
h, _, _ := monthlyHandler(t)
for _, tc := range []struct {
filter string
total int
}{{"&activity=used", 1}, {"&activity=zero", 3}, {"&region=cross_region", 1}, {"&region=APAC", 2}, {"&department_id=child", 3}, {"&department_id=root", 4}, {"&search=" + url.QueryEscape("同名示例"), 2}, {"&search=" + url.QueryEscape("子部门"), 3}, {"&search=%25_", 0}} {
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+tc.filter, "attribution-admin")
var payload struct{ Data monthlyPeople }
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &payload) != nil || payload.Data.Total != tc.total || payload.Data.Questions != 7 {
t.Fatal("person-scope filter did not preserve source totals or person dedup", tc.filter)
}
for _, r := range payload.Data.Rows {
if tc.filter == "&activity=zero" && r.Questions != 0 {
t.Fatal("used member labeled zero")
}
}
}
}
func TestQAMonthlyReadBodyRejectsDuplicateNullTrailingAndOversizedInput(t *testing.T) {
h, _, reader := monthlyHandler(t)
for _, body := range []string{`{"month":"2026-10","month":"2026-10"}`, `{"month":"2026-10","search":null}`, `{"month":"2026-10","page":"1"}`, `{"month":"2026-10"}{}`, `[]`, `{"month":"2026-10","search":"` + strings.Repeat("x", 9000) + `"}`} {
r := httptest.NewRequest("POST", "/api/v1/qa-ops/usage/people", strings.NewReader(body))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
r.Header.Set("X-Governance-Action", "qa-usage-read")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 400 {
t.Fatal("invalid read body reached source", w.Code)
}
}
for _, method := range []string{"POST", "GET"} {
r := httptest.NewRequest(method, "/api/v1/qa-ops/usage/people?search=sensitive-example", strings.NewReader(`{"month":"2026-10"}`))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 400 && w.Code != 405 {
t.Fatal("URL-based name query accepted")
}
}
if reader.calls != 0 {
t.Fatal("invalid body read production source")
}
}
func TestQAMonthlyPastLeapMonthUsesClosedCalendarWindow(t *testing.T) {
h, _, reader := monthlyHandler(t)
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2024-02", "attribution-admin")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if reader.query.Window.End.Format(time.RFC3339) != "2024-03-01T00:00:00+08:00" || reader.query.Window.End.Sub(reader.query.Window.Start) != 29*24*time.Hour {
t.Fatal("leap month boundary incorrect")
}
var raw struct{ Data struct{ Partial bool } }
_ = json.Unmarshal(w.Body.Bytes(), &raw)
if raw.Data.Partial {
t.Fatal("past natural month mislabeled as current progress")
}
}
+167
View File
@@ -0,0 +1,167 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"knowledge-governance/internal/dobotaiops"
)
// No raw SQL, source credential, arbitrary tenant or write method crosses this boundary.
type QAOpsReader interface {
ListRecords(context.Context, dobotaiops.RecordQuery) (dobotaiops.RecordPage, error)
GetRecord(context.Context, string, string) (dobotaiops.Record, error)
Available(context.Context) error
Safety() dobotaiops.SourceSafety
}
func authorizeQAOps(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
identity, ok := sessionFrom(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "问答运营需要登录后访问"})
return false
}
// Until the employee-data visibility scope is approved, do not widen access
// to all pre-existing viewers/engineers merely because this endpoint is GET.
if identity.Role != roleAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "尚未配置员工问答可见范围,当前仅管理员可访问"})
return false
}
return true
}
func qaOpsStatus(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
state := "disabled"
code := "source_not_configured"
warnings := []string{}
if config.QAOpsEnabled {
state = "unavailable"
code = "source_unavailable"
}
if config.QAOpsProblem != "" {
state = "blocked"
code = "source_configuration_invalid"
}
if config.QAOps != nil && config.QAOps.Available(r.Context()) == nil {
state = "connected"
code = ""
safety := config.QAOps.Safety()
if safety.DatabaseTemp {
warnings = append(warnings, "database_temp_enabled")
}
if !safety.SSL {
warnings = append(warnings, "database_transport_unencrypted")
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"state": state, "code": code, "warnings": warnings, "read_only": true, "source_version": "2.0", "channel": "dingtalk"}})
}
func listQAOpsRecords(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
if config.QAOps == nil || config.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
values := r.URL.Query()
allowed := map[string]bool{"start": true, "end": true, "keyword": true, "cursor": true, "limit": true}
for key, items := range values {
if !allowed[key] || len(items) != 1 {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
}
window, err := dobotaiops.NewWindow(config.QAOpsTenantKey, values.Get("start"), values.Get("end"), 90*24*time.Hour, config.Now())
if err != nil {
qaOpsError(w, err)
return
}
limit := 20
if raw := values.Get("limit"); raw != "" {
limit, err = strconv.Atoi(raw)
if err != nil || limit < 1 || limit > 100 {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
}
projection, err := recordAttribution(r.Context(), config)
if err != nil {
qaOpsError(w, err)
return
}
page, err := config.QAOps.ListRecords(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: values.Get("keyword"), Cursor: values.Get("cursor"), Limit: limit})
if err != nil {
qaOpsError(w, err)
return
}
enrichQAPage(&page, projection)
if err := enrichQAQualityPage(r.Context(), config, &page); err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page})
}
func getQAOpsRecord(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
if config.QAOps == nil || config.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
if r.URL.RawQuery != "" {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
projection, err := recordAttribution(r.Context(), config)
if err != nil {
qaOpsError(w, err)
return
}
item, err := config.QAOps.GetRecord(r.Context(), config.QAOpsTenantKey, r.PathValue("id"))
if err != nil {
qaOpsError(w, err)
return
}
enrichQARecord(&item, projection)
if err := enrichQAQualityRecord(r.Context(), config, &item); err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": item})
}
func qaOpsError(w http.ResponseWriter, err error) {
status := http.StatusServiceUnavailable
code := "source_unavailable"
message := "问答数据源不可用,请检查独立只读连接;原有知识治理功能不受影响"
if errors.Is(err, errQAOrganization) {
code = "organization_unavailable"
message = "通讯录或区域配置读取失败,未将不可用数据伪装成空列,请重试"
}
if errors.Is(err, dobotaiops.ErrInvalidQuery) {
status = http.StatusBadRequest
code = "invalid_query"
message = "查询参数无效:请检查时间范围、关键词长度或重新查询后翻页"
}
if errors.Is(err, dobotaiops.ErrNotReadOnly) {
code = "source_not_read_only"
message = "问答连接权限校验未通过,已阻止读取,请联系管理员"
}
if errors.Is(err, dobotaiops.ErrRecordNotFound) {
status = http.StatusNotFound
code = "record_not_found"
message = "未找到当前授权范围内的问答记录"
}
writeJSON(w, status, map[string]string{"error": message, "code": code})
}
+87
View File
@@ -0,0 +1,87 @@
package httpapi
import (
"context"
"errors"
"knowledge-governance/internal/dobotaiops"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type qaOpsSpy struct {
query dobotaiops.RecordQuery
tenant, id string
calls int
err error
}
func (s *qaOpsSpy) ListRecords(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
s.query = q
s.calls++
return dobotaiops.RecordPage{Items: []dobotaiops.Record{}}, s.err
}
func (s *qaOpsSpy) GetRecord(_ context.Context, tenant, id string) (dobotaiops.Record, error) {
s.tenant = tenant
s.id = id
s.calls++
return dobotaiops.Record{}, s.err
}
func (s *qaOpsSpy) Available(context.Context) error { return s.err }
func (s *qaOpsSpy) Safety() dobotaiops.SourceSafety {
return dobotaiops.SourceSafety{DatabaseTemp: true, SSL: false}
}
func TestQAOperationsRequireExplicitSessionAndServerSelectedTenant(t *testing.T) {
spy := &qaOpsSpy{}
auth := newFakeAuthStore()
for token, user := range map[string]string{"admin-qa-fixture": "usr-boss", "viewer-qa-fixture": "usr-eye", "engineer-qa-fixture": "usr-eng"} {
auth.sessions[token] = user
}
handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOps: spy, QAOpsEnabled: true, QAOpsTenantKey: "approved-tenant", Auth: &AuthConfig{Store: auth}})
if err != nil {
t.Fatal(err)
}
base := "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z"
for _, tc := range []struct {
token, path string
status int
}{
{"", base, http.StatusUnauthorized}, {"viewer-qa-fixture", base, http.StatusForbidden}, {"engineer-qa-fixture", base, http.StatusForbidden},
{"admin-qa-fixture", base + "&tenant_key=another-tenant", http.StatusBadRequest},
{"admin-qa-fixture", base + "&limit=0", http.StatusBadRequest},
{"admin-qa-fixture", base + "&keyword=a&keyword=b", http.StatusBadRequest},
{"admin-qa-fixture", base + "&keyword=test", http.StatusOK},
} {
request := httptest.NewRequest(http.MethodGet, tc.path, nil)
if tc.token != "" {
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tc.token})
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != tc.status {
t.Fatalf("status=%d want=%d body=%s", response.Code, tc.status, response.Body.String())
}
}
if spy.calls != 1 || spy.query.Window.TenantKey != "approved-tenant" || spy.query.Keyword != "test" {
t.Fatalf("unauthorized/invalid reads reached source: %+v", spy)
}
spy.err = errors.New("sensitive-driver-detail-not-for-browser")
request := httptest.NewRequest(http.MethodGet, base, nil)
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "admin-qa-fixture"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || strings.Contains(response.Body.String(), "sensitive-driver-detail") {
t.Fatalf("source failure leaked or became empty success: %s", response.Body.String())
}
if response.Header().Get("Cache-Control") != "no-store" {
t.Fatal("employee answers must not be cached")
}
unauth, _ := NewHandler(Config{Store: &fakeJobStore{}, QAOps: spy, QAOpsTenantKey: "approved-tenant"})
response = httptest.NewRecorder()
unauth.ServeHTTP(response, httptest.NewRequest(http.MethodGet, base, nil))
if response.Code != http.StatusUnauthorized {
t.Fatal("auth-disabled governance must not expose production answers")
}
}
+14
View File
@@ -0,0 +1,14 @@
package httpapi
import (
"net/http"
"testing"
)
func TestQAOperationsRoleGatePrecedesGenericGETShortcut(t *testing.T) {
for _, path := range []string{"/api/v1/qa-ops/status", "/api/v1/qa-ops/records", "/api/v1/qa-ops/records/some-id"} {
if role := classifyRole(http.MethodGet, path); role != roleAdmin {
t.Fatalf("QA employee data route classified as %s, must require admin: %s", role, path)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestQAOperationsFailClosedWithoutBreakingGovernance(t *testing.T) {
auth := newFakeAuthStore()
auth.sessions["admin-qa-fixture"] = "usr-boss"
handler, err := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z", nil)
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "admin-qa-fixture"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured source must not be an empty list: status=%d body=%s", response.Code, response.Body.String())
}
health := httptest.NewRecorder()
handler.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/livez", nil))
if health.Code != http.StatusOK {
t.Fatalf("external source must not stop governance liveness: %d", health.Code)
}
}
+69
View File
@@ -0,0 +1,69 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"knowledge-governance/internal/qaopsorg"
"net/http"
)
type QAOrgStore interface {
GetView(context.Context) (qaopsorg.State, error)
PreviewRules(context.Context, qaopsorg.SaveRequest) (qaopsorg.State, error)
SaveRules(context.Context, qaopsorg.SaveRequest, string) (qaopsorg.State, error)
}
func qaOrganization(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
if r.URL.RawQuery != "" {
writeOrgError(w, qaopsorg.ErrInvalid)
return
}
if config.QAOrg == nil {
writeOrgError(w, qaopsorg.ErrUnavailable)
return
}
var result qaopsorg.State
var err error
if r.Method == http.MethodGet {
result, err = config.QAOrg.GetView(r.Context())
} else {
if !authorizeQAOrganizationWrite(w, r) {
return
}
var request qaopsorg.SaveRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&request) != nil || decoder.Decode(&struct{}{}) != io.EOF || request.DirectoryID == "" || request.Rules == nil {
writeOrgError(w, qaopsorg.ErrInvalid)
return
}
if r.Method == http.MethodPost {
result, err = config.QAOrg.PreviewRules(r.Context(), request)
} else {
identity, _ := sessionFrom(r.Context())
result, err = config.QAOrg.SaveRules(r.Context(), request, identity.Username)
}
}
if err != nil {
writeOrgError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": result})
}
func writeOrgError(w http.ResponseWriter, err error) {
status, message := 503, "通讯录或区域配置存储不可用,未展示为空数据"
switch {
case errors.Is(err, qaopsorg.ErrInvalid):
status, message = 400, "部门树或区域配置无效,请核对重复、缺失路径及区域"
case errors.Is(err, qaopsorg.ErrConflict):
status, message = 409, "通讯录或区域配置已变化,请重新读取后预览"
case errors.Is(err, qaopsorg.ErrNoDirectory):
status, message = 409, "尚无完整通讯录快照,不能保存区域配置"
}
writeJSON(w, status, map[string]string{"error": message})
}
+33
View File
@@ -0,0 +1,33 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestQAOrganizationSameOriginThroughReverseProxy(t *testing.T) {
auth := newFakeAuthStore()
auth.sessions["org-proxy-admin"] = "usr-boss"
spy := &orgSpy{}
handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOrg: spy, Auth: &AuthConfig{Store: auth}})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
site string
status int
}{{"same-origin", 200}, {"cross-site", 403}, {"same-site", 403}} {
req := httptest.NewRequest("PUT", "http://internal-api:8080/api/v1/qa-ops/organization/regions", strings.NewReader(`{"directory_id":"d","base_revision":"","rules":[]}`))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "org-proxy-admin"})
req.Header.Set("X-Governance-Action", "qa-organization")
req.Header.Set("Origin", "http://frontend:8088")
req.Header.Set("Sec-Fetch-Site", tc.site)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != tc.status {
t.Fatalf("proxy fetch site %s got %d want %d", tc.site, res.Code, tc.status)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/url"
"strings"
)
type QAOrgSyncStore interface {
GetSyncStatus(context.Context) (qaopsorg.SyncStatus, error)
EnqueueSync(context.Context, string, string) (qaopsorg.SyncReceipt, bool, error)
}
func authorizeQAOrganizationWrite(w http.ResponseWriter, r *http.Request) bool {
return authorizeQAIntent(w, r, "qa-organization")
}
func authorizeQAIntent(w http.ResponseWriter, r *http.Request, intent string) bool {
if r.Header.Get("X-Governance-Action") != intent || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
writeJSON(w, 403, map[string]string{"error": "此操作需要同源操作意图"})
return false
}
// Fetch Metadata survives same-origin proxies that rewrite Host; untrusted
// forwarded headers must not grant cross-origin write authority.
if raw := r.Header.Get("Origin"); raw != "" && r.Header.Get("Sec-Fetch-Site") != "same-origin" {
origin, err := url.Parse(raw)
if err != nil || (origin.Scheme != "http" && origin.Scheme != "https") || origin.Host != r.Host {
writeJSON(w, 403, map[string]string{"error": "拒绝跨站配置请求"})
return false
}
}
return true
}
func qaOrganizationSync(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) {
return
}
if r.URL.RawQuery != "" {
writeOrgError(w, qaopsorg.ErrInvalid)
return
}
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodGet {
if c.QAOrgSync == nil {
writeJSON(w, 200, map[string]any{"data": qaopsorg.SyncStatus{}})
return
}
result, err := c.QAOrgSync.GetSyncStatus(r.Context())
if err != nil {
writeOrgError(w, err)
return
}
result.Configured = c.QAOrgSyncEnabled
writeJSON(w, 200, map[string]any{"data": result})
return
}
if !authorizeQAOrganizationWrite(w, r) {
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" || len(key) > 128 || strings.TrimSpace(key) != key {
writeJSON(w, 400, map[string]string{"error": "同步请求需要有效 Idempotency-Key,重试须复用原标识"})
return
}
var body *struct{}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
decoder.DisallowUnknownFields()
if decoder.Decode(&body) != nil || body == nil || decoder.Decode(&struct{}{}) != io.EOF {
writeOrgError(w, qaopsorg.ErrInvalid)
return
}
if !c.QAOrgSyncEnabled || c.QAOrgSync == nil {
writeJSON(w, 503, map[string]string{"error": "通讯录后台同步未接线,未创建任务"})
return
}
identity, _ := sessionFrom(r.Context())
receipt, replayed, err := c.QAOrgSync.EnqueueSync(r.Context(), key, identity.Username)
if err != nil {
if errors.Is(err, qaopsorg.ErrSyncWorkerUnavailable) {
writeJSON(w, 503, map[string]string{"error": "通讯录执行器未在线,未创建任务;请检查后台同步配置"})
return
}
writeOrgError(w, err)
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, 202, map[string]any{"data": receipt, "operation": operation})
}
@@ -0,0 +1,20 @@
package httpapi
import (
"net/http/httptest"
"strings"
"testing"
)
func TestGenericJobCreationCannotBypassDirectorySyncAdminPath(t *testing.T) {
h, err := NewHandler(Config{Store: &fakeJobStore{}})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/v1/jobs", strings.NewReader(`{"type":"qaops.directory.sync.fixture","payload":{"triggered_by":"forged"},"idempotency_key":"bypass"}`))
res := httptest.NewRecorder()
h.ServeHTTP(res, req)
if res.Code != 400 {
t.Fatalf("reserved sync job bypassed its dedicated admission path: %d", res.Code)
}
}
+85
View File
@@ -0,0 +1,85 @@
package httpapi
import (
"context"
"encoding/json"
"io"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type orgSyncSpy struct {
calls int
key, actor string
replay bool
err error
}
func (s *orgSyncSpy) GetSyncStatus(context.Context) (qaopsorg.SyncStatus, error) {
s.calls++
return qaopsorg.SyncStatus{Configured: true, WorkerReady: true}, s.err
}
func (s *orgSyncSpy) EnqueueSync(_ context.Context, key, actor string) (qaopsorg.SyncReceipt, bool, error) {
s.calls++
s.key = key
s.actor = actor
return qaopsorg.SyncReceipt{JobID: "fixture-job"}, s.replay, s.err
}
func TestQAOrganizationSyncRequiresAdminIntentAndDurableOperation(t *testing.T) {
spy := &orgSyncSpy{}
auth := newFakeAuthStore()
auth.sessions["sync-admin"] = "usr-boss"
auth.sessions["sync-viewer"] = "usr-eye"
handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOrgSync: spy, QAOrgSyncEnabled: true, Auth: &AuthConfig{Store: auth}})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
method, token, key, action, origin, body string
status int
}{
{"GET", "", "", "", "", "", 401}, {"GET", "sync-viewer", "", "", "", "", 403}, {"GET", "sync-admin", "", "", "", "", 200},
{"POST", "sync-admin", "op-one", "", "", "{}", 403}, {"POST", "sync-admin", "op-one", "qa-organization", "https://evil.invalid", "{}", 403},
{"POST", "sync-admin", "", "qa-organization", "", "{}", 400}, {"POST", "sync-admin", " op-one ", "qa-organization", "", "{}", 400},
{"POST", "sync-admin", "op-one", "qa-organization", "", `null`, 400},
{"POST", "sync-admin", "op-one", "qa-organization", "", `{"tenant":"forged"}`, 400}, {"POST", "sync-admin", "op-one", "qa-organization", "", "{} {}", 400},
{"POST", "sync-admin", "op-one", "qa-organization", "", "{}", 202},
} {
req := httptest.NewRequest(tc.method, "/api/v1/qa-ops/organization/sync", strings.NewReader(tc.body))
if tc.token != "" {
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tc.token})
}
req.Header.Set("Idempotency-Key", tc.key)
req.Header.Set("X-Governance-Action", tc.action)
req.Header.Set("Origin", tc.origin)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != tc.status {
t.Fatalf("%+v got %d: %s", tc, res.Code, res.Body.String())
}
}
if spy.calls != 2 || spy.key != "op-one" || spy.actor == "" {
t.Fatalf("invalid request reached store: %+v", spy)
}
spy.replay = true
req := httptest.NewRequest("POST", "/api/v1/qa-ops/organization/sync", strings.NewReader("{}"))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sync-admin"})
req.Header.Set("Idempotency-Key", "op-one")
req.Header.Set("X-Governance-Action", "qa-organization")
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
var body map[string]any
if json.Unmarshal(res.Body.Bytes(), &body) != nil || body["operation"] != "replayed" {
t.Fatal("operation replay not exposed")
}
disabled, _ := NewHandler(Config{Store: &fakeJobStore{}, QAOrgSync: spy, Auth: &AuthConfig{Store: auth}})
req.Body = io.NopCloser(strings.NewReader("{}"))
res = httptest.NewRecorder()
disabled.ServeHTTP(res, req)
if res.Code != 503 {
t.Fatal("disabled sync enqueued")
}
}
+82
View File
@@ -0,0 +1,82 @@
package httpapi
import (
"context"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type orgSpy struct {
calls int
actor string
err error
}
func (s *orgSpy) GetView(context.Context) (qaopsorg.State, error) {
s.calls++
return qaopsorg.State{State: "empty"}, s.err
}
func (s *orgSpy) PreviewRules(context.Context, qaopsorg.SaveRequest) (qaopsorg.State, error) {
s.calls++
return qaopsorg.State{}, s.err
}
func (s *orgSpy) SaveRules(_ context.Context, _ qaopsorg.SaveRequest, actor string) (qaopsorg.State, error) {
s.calls++
s.actor = actor
return qaopsorg.State{}, s.err
}
func TestQAOrganizationGuardsIdentityIntentAndErrors(t *testing.T) {
spy := &orgSpy{}
auth := newFakeAuthStore()
auth.sessions["org-admin"] = "usr-boss"
auth.sessions["org-viewer"] = "usr-eye"
handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOrg: spy, Auth: &AuthConfig{Store: auth}})
if err != nil {
t.Fatal(err)
}
base := "/api/v1/qa-ops/organization"
for _, tc := range []struct {
method, path, token, action, origin, body string
status int
}{
{"GET", base, "", "", "", "", 401},
{"GET", base, "org-viewer", "", "", "", 403},
{"GET", base, "org-admin", "", "", "", 200},
{"GET", base + "?tenant=another", "org-admin", "", "", "", 400},
{"PUT", base + "/regions", "org-admin", "", "", "{}", 403},
{"PUT", base + "/regions", "org-admin", "qa-organization", "https://untrusted.invalid", "{}", 403},
{"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"updated_by":"forged"}`, 400},
{"PUT", base + "/regions", "org-admin", "qa-organization", "", `{} {}`, 400},
{"PUT", base + "/regions", "org-admin", "qa-organization", "", `{}`, 400},
{"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":null}`, 400},
{"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":[]}`, 200},
{"POST", base + "/preview", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":[]}`, 200},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.token != "" {
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tc.token})
}
req.Header.Set("X-Governance-Action", tc.action)
req.Header.Set("Origin", tc.origin)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != tc.status {
t.Fatalf("%s %s got %d want %d: %s", tc.method, tc.path, res.Code, tc.status, res.Body.String())
}
}
if spy.calls != 3 || spy.actor == "" || spy.actor == "forged" {
t.Fatalf("invalid requests reached store or actor not stamped: %+v", spy)
}
spy.err = qaopsorg.ErrConflict
req := httptest.NewRequest("PUT", base+"/regions", strings.NewReader(`{"directory_id":"d","base_revision":"","rules":[]}`))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "org-admin"})
req.Header.Set("X-Governance-Action", "qa-organization")
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != 409 {
t.Fatal("stale configuration not reported as conflict")
}
}
@@ -0,0 +1,62 @@
package httpapi
import (
"net/http/httptest"
"os"
"regexp"
"testing"
)
// HTTP LAN/tailnet origins may omit Fetch Metadata. nginx must preserve the
// external authority including its port so Origin can be checked without it.
func TestQAProxyPreservesBrowserAuthority(t *testing.T) {
config, err := os.ReadFile("../../deploy/nginx.conf")
if err != nil {
t.Fatal(err)
}
hosts := regexp.MustCompile(`(?m)^\s*proxy_set_header\s+Host\s+([^;]+);`).FindAllStringSubmatch(string(config), -1)
if len(hosts) < 2 {
t.Fatal("API and health proxy Host directives must both be explicit")
}
for _, host := range hosts {
if host[1] != "$http_host" {
t.Fatalf("proxy drops browser authority/port: got %q, want $http_host", host[1])
}
}
}
func TestQAIntentWithoutFetchMetadataChecksExactAuthority(t *testing.T) {
for _, tc := range []struct {
name, host, origin, forwarded, site, intent string
allowed bool
}{
{"LAN same authority", "192.168.50.134:8088", "http://192.168.50.134:8088", "", "", "qa-quality", true},
{"tailnet same authority", "100.92.49.78:8088", "http://100.92.49.78:8088", "", "", "qa-quality", true},
{"proxy stripped port", "192.168.50.134", "http://192.168.50.134:8088", "", "", "qa-quality", false},
{"foreign origin", "192.168.50.134:8088", "http://foreign.invalid:8088", "", "", "qa-quality", false},
{"different port", "192.168.50.134:8088", "http://192.168.50.134:8089", "", "", "qa-quality", false},
{"forwarded header cannot grant", "192.168.50.134:8088", "http://foreign.invalid:8088", "foreign.invalid:8088", "", "qa-quality", false},
{"opaque origin", "192.168.50.134:8088", "null", "", "", "qa-quality", false},
{"explicit cross site", "192.168.50.134:8088", "http://192.168.50.134:8088", "", "cross-site", "qa-quality", false},
{"missing intent", "192.168.50.134:8088", "http://192.168.50.134:8088", "", "", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "http://"+tc.host+"/api/v1/qa-ops/quality/query", nil)
req.Header.Set("Origin", tc.origin)
req.Header.Set("X-Governance-Action", tc.intent)
if tc.forwarded != "" {
req.Header.Set("X-Forwarded-Host", tc.forwarded)
}
if tc.site != "" {
req.Header.Set("Sec-Fetch-Site", tc.site)
}
rec := httptest.NewRecorder()
if got := authorizeQAIntent(rec, req, "qa-quality"); got != tc.allowed {
t.Fatalf("authorized=%v want %v, HTTP=%d", got, tc.allowed, rec.Code)
}
if !tc.allowed && rec.Code != 403 {
t.Fatalf("rejected authority should be 403, got %d", rec.Code)
}
})
}
}
+264
View File
@@ -0,0 +1,264 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsquality"
"net/http"
"strconv"
"time"
)
type QAQualityStore interface {
ObserveKeys(context.Context, []dobotaiops.QualityKey) (map[string]qaopsquality.View, error)
Observe(context.Context, []dobotaiops.Record) (map[string]qaopsquality.View, error)
Save(context.Context, dobotaiops.Record, qaopsquality.SaveRequest, string) (qaopsquality.Receipt, error)
Columns(context.Context, []dobotaiops.QualityKey) ([]string, error)
History(context.Context, string, int64, int64) (qaopsquality.History, error)
Snapshot(context.Context, string, int64) (qaopsquality.Snapshot, error)
}
type qaQualityReader interface {
ReadQualityScope(context.Context, dobotaiops.RecordQuery) (dobotaiops.QualityScope, error)
}
func writeQualityError(w http.ResponseWriter, err error) {
status, code, message := 503, "quality_unavailable", "质量留档/复核存储不可用,未将失败伪装为空值或零统计"
switch {
case errors.Is(err, qaopsquality.ErrInvalid) || errors.Is(err, dobotaiops.ErrInvalidQuery):
status, code, message = 400, "invalid_quality_request", "质量操作参数无效:请核对结论、理由、版本和筛选范围(最多5000条/32MiB)"
case errors.Is(err, qaopsquality.ErrConflict):
status, code, message = 409, "quality_conflict", "原始问答或人工复核版本已变化,请重新读取问答后核对;未覆盖现有结论"
case errors.Is(err, qaopsquality.ErrSnapshotMissing):
status, code, message = 409, "quality_input_metadata_only", "此版本仅留存指纹与复检元数据,未采集原文;未编造空问题或空答案"
case errors.Is(err, qaopsquality.ErrNotFound):
status, code, message = 404, "quality_input_not_found", "未找到当前授权范围的输入版本"
}
writeJSON(w, status, map[string]string{"error": message, "code": code})
}
func enrichQAQualityRecord(ctx context.Context, c Config, item *dobotaiops.Record) error {
if c.QAQuality == nil {
return nil
}
views, err := c.QAQuality.Observe(ctx, []dobotaiops.Record{*item})
if err != nil {
return err
}
v, ok := views[item.ID]
if !ok {
return qaopsquality.ErrUnavailable
}
item.Quality, err = json.Marshal(v)
return err
}
func enrichQAQualityPage(ctx context.Context, c Config, page *dobotaiops.RecordPage) error {
if c.QAQuality == nil {
return nil
}
views, err := c.QAQuality.Observe(ctx, page.Items)
if err != nil {
return err
}
for i := range page.Items {
v, ok := views[page.Items[i].ID]
if !ok {
return qaopsquality.ErrUnavailable
}
page.Items[i].Quality, err = json.Marshal(v)
if err != nil {
return err
}
}
columns, err := c.QAQuality.Columns(ctx, page.ScopeQuality)
if err != nil {
return err
}
dobotaiops.AddRecordColumns(page, columns...)
return nil
}
func walkQualityJSON(d *json.Decoder) error {
token, err := d.Token()
if err != nil {
return err
}
if token == nil {
return qaopsquality.ErrInvalid
}
if delimiter, ok := token.(json.Delim); ok {
if delimiter != '{' && delimiter != '[' {
return qaopsquality.ErrInvalid
}
keys := map[string]bool{}
for d.More() {
if delimiter == '{' {
key, e := d.Token()
name, ok := key.(string)
if e != nil || !ok || keys[name] {
return qaopsquality.ErrInvalid
}
keys[name] = true
}
if walkQualityJSON(d) != nil {
return qaopsquality.ErrInvalid
}
}
_, err = d.Token()
return err
}
return nil
}
func decodeQualityJSON(w http.ResponseWriter, r *http.Request, target any, required ...string) error {
if r.URL.RawQuery != "" {
return qaopsquality.ErrInvalid
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return qaopsquality.ErrInvalid
}
var top map[string]json.RawMessage
if json.Unmarshal(raw, &top) != nil || top == nil {
return qaopsquality.ErrInvalid
}
for _, key := range required {
if _, ok := top[key]; !ok {
return qaopsquality.ErrInvalid
}
}
check := json.NewDecoder(bytes.NewReader(raw))
if walkQualityJSON(check) != nil {
return qaopsquality.ErrInvalid
}
var extra any
if check.Decode(&extra) != io.EOF {
return qaopsquality.ErrInvalid
}
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if d.Decode(target) != nil || d.Decode(&extra) != io.EOF {
return qaopsquality.ErrInvalid
}
return nil
}
func qaQualityReview(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-quality") {
return
}
var request qaopsquality.SaveRequest
if decodeQualityJSON(w, r, &request, "input_hash", "input_epoch", "base_revision", "nonce", "fields", "reason") != nil || qaopsquality.Validate(request) != nil {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
if c.QAQuality == nil || c.QAOps == nil || c.QAOpsTenantKey == "" {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
record, err := c.QAOps.GetRecord(r.Context(), c.QAOpsTenantKey, r.PathValue("id"))
if err != nil {
qaOpsError(w, err)
return
}
identity, _ := sessionFrom(r.Context())
receipt, err := c.QAQuality.Save(r.Context(), record, request, identity.Username)
if err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": receipt})
}
func qaQualityQuery(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-quality") {
return
}
var request struct {
Start string `json:"start"`
End string `json:"end"`
Keyword string `json:"keyword"`
}
if decodeQualityJSON(w, r, &request) != nil {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
window, err := dobotaiops.NewWindow(c.QAOpsTenantKey, request.Start, request.End, 90*24*time.Hour, c.Now())
if err != nil {
writeQualityError(w, err)
return
}
reader, ok := c.QAOps.(qaQualityReader)
if !ok || c.QAQuality == nil {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
scope, err := reader.ReadQualityScope(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: request.Keyword})
if err != nil {
qaOpsError(w, err)
return
}
views, err := c.QAQuality.ObserveKeys(r.Context(), scope.Keys)
if err != nil || len(views) != len(scope.Keys) {
if err == nil {
err = qaopsquality.ErrUnavailable
}
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": map[string]any{"summary": qaopsquality.Summarize(views), "start": window.Start, "end": window.End, "keyword": request.Keyword, "as_of": scope.AsOf, "ai_state": "not_configured", "scope": "whole_filter"}})
}
func qaQualityHistory(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) {
return
}
if c.QAQuality == nil {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
values := r.URL.Query()
for key, v := range values {
if (key != "review_before" && key != "check_before") || len(v) != 1 {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
}
cursors := map[string]int64{}
for _, key := range []string{"review_before", "check_before"} {
raw := values.Get(key)
if raw == "" {
continue
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
cursors[key] = n
}
value, err := c.QAQuality.History(r.Context(), r.PathValue("id"), cursors["review_before"], cursors["check_before"])
if err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": value})
}
func qaQualitySnapshot(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) {
return
}
epoch, err := strconv.ParseInt(r.PathValue("epoch"), 10, 64)
if err != nil || epoch < 1 || r.URL.RawQuery != "" {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
if c.QAQuality == nil {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
value, err := c.QAQuality.Snapshot(r.Context(), r.PathValue("id"), epoch)
if err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": value})
}
+179
View File
@@ -0,0 +1,179 @@
package httpapi
import (
"context"
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsquality"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type qualityFake struct {
actor string
calls int
fail bool
}
func (s *qualityFake) Observe(_ context.Context, records []dobotaiops.Record) (map[string]qaopsquality.View, error) {
s.calls++
if s.fail {
return nil, qaopsquality.ErrUnavailable
}
out := map[string]qaopsquality.View{}
for _, r := range records {
e, _ := qaopsquality.EvidenceFor(r)
out[r.ID] = qaopsquality.Project(e.Hash, 1, 0, qaopsquality.Check{InputEpoch: 1, InputHash: e.Hash, Result: dobotaiops.CheckAnswerCoverage(r.Answer)}, nil)
}
return out, nil
}
func (s *qualityFake) ObserveKeys(_ context.Context, keys []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) {
s.calls++
out := map[string]qaopsquality.View{}
for _, key := range keys {
out[key.ID] = qaopsquality.Project(key.InputHash, 1, 0, qaopsquality.Check{Result: dobotaiops.CheckMarkerCoverage(key.Marker)}, nil)
}
return out, nil
}
func (s *qualityFake) Save(_ context.Context, r dobotaiops.Record, q qaopsquality.SaveRequest, actor string) (qaopsquality.Receipt, error) {
s.actor = actor
v := qaopsquality.Project(q.InputHash, 1, 1, qaopsquality.Check{Result: dobotaiops.CheckAnswerCoverage(r.Answer)}, &qaopsquality.Review{InputEpoch: 1, InputHash: q.InputHash, Fields: q.Fields, Reason: q.Reason, Reviewer: actor})
return qaopsquality.Receipt{View: v, SavedRevision: 1}, nil
}
func (s *qualityFake) Columns(_ context.Context, keys []dobotaiops.QualityKey) ([]string, error) {
return []string{"is_correct", "product_category"}, nil
}
func (s *qualityFake) History(context.Context, string, int64, int64) (qaopsquality.History, error) {
return qaopsquality.History{Reviews: []qaopsquality.Review{}, Checks: []qaopsquality.Check{}}, nil
}
func (s *qualityFake) Snapshot(context.Context, string, int64) (qaopsquality.Snapshot, error) {
return qaopsquality.Snapshot{}, qaopsquality.ErrNotFound
}
type qualitySource struct {
attributedReader
record dobotaiops.Record
tenant string
scopeCalls int
}
func (s *qualitySource) GetRecord(_ context.Context, tenant, id string) (dobotaiops.Record, error) {
s.tenant = tenant
return s.record, nil
}
func (s *qualitySource) ListRecords(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
return dobotaiops.RecordPage{Items: []dobotaiops.Record{s.record}, Columns: []string{"question"}, Total: 2, AsOf: time.Now()}, nil
}
func (s *qualitySource) ReadQualityScope(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.QualityScope, error) {
s.scopeCalls++
s.tenant = q.Window.TenantKey
second := s.record
second.ID = "second-turn"
keys := []dobotaiops.QualityKey{}
for _, r := range []dobotaiops.Record{s.record, second} {
e, _ := qaopsquality.EvidenceFor(r)
keys = append(keys, dobotaiops.QualityKey{ID: r.ID, InputHash: e.Hash, Marker: e.Result.Verdict == "uncovered", Source: r.SourceCoverage})
}
return dobotaiops.QualityScope{Keys: keys, AsOf: time.Now()}, nil
}
func qualityTestHandler(t *testing.T) (http.Handler, *qualityFake, *qualitySource) {
t.Helper()
store := &qualityFake{}
source := &qualitySource{record: dobotaiops.Record{ID: "fixture-turn", Status: "succeeded", Question: "示例", Answer: "暂无已发布"}}
auth := newFakeAuthStore()
auth.sessions["quality-admin"] = "usr-boss"
auth.sessions["quality-viewer"] = "usr-eye"
h, e := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: source, QAOpsTenantKey: "fixed", QAQuality: store, Now: func() time.Time { return time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) }})
if e != nil {
t.Fatal(e)
}
return h, store, source
}
func qualityRequest(h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Governance-Action", "qa-quality")
if token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestQAQualityDetailListAndWholeScopeCounts(t *testing.T) {
h, store, source := qualityTestHandler(t)
w := qualityRequest(h, "GET", "/api/v1/qa-ops/records/fixture-turn", "quality-admin", "")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"quality"`) {
t.Fatal("persisted quality absent in detail", w.Code)
}
w = qualityRequest(h, "GET", "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z", "quality-admin", "")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"is_correct"`) {
t.Fatal("whole-filter quality columns absent")
}
w = qualityRequest(h, "POST", "/api/v1/qa-ops/quality/query", "quality-admin", `{"start":"2026-10-14T00:00:00Z","end":"2026-10-15T00:00:00Z","keyword":""}`)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"unreviewed":2`) || source.scopeCalls != 1 || source.tenant != "fixed" || store.calls < 2 {
t.Fatal("quality summary estimated from page instead of scope", w.Code, w.Body.String())
}
var counts struct {
Data struct {
Summary qaopsquality.Summary `json:"summary"`
} `json:"data"`
}
if json.Unmarshal(w.Body.Bytes(), &counts) != nil || counts.Data.Summary.Rates.Coverage.Denominator != 2 || counts.Data.Summary.Rates.Coverage.Numerator != 0 || counts.Data.Summary.Rates.Correctness.Percent != nil {
t.Fatal("confirmed quality metrics were not computed for the whole scope")
}
store.fail = true
w = qualityRequest(h, "GET", "/api/v1/qa-ops/records/fixture-turn", "quality-admin", "")
if w.Code != 503 || !strings.Contains(w.Body.String(), "quality_unavailable") {
t.Fatal("quality storage failure presented as empty success")
}
}
func TestQAQualityReviewUsesSessionActorAndStrictReadOnlySourceBoundary(t *testing.T) {
h, store, source := qualityTestHandler(t)
e, _ := qaopsquality.EvidenceFor(source.record)
q := qaopsquality.SaveRequest{InputHash: e.Hash, InputEpoch: 1, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "uncovered", Correctness: "pending"}, Reason: "人工核对"}
raw, _ := json.Marshal(q)
path := "/api/v1/qa-ops/records/fixture-turn/reviews"
w := qualityRequest(h, "POST", path, "quality-admin", string(raw))
if w.Code != 200 || store.actor != "boss" || source.tenant != "fixed" {
t.Fatal("manual review failed or actor came from request", w.Code, store.actor)
}
for _, tc := range []struct {
token, body string
status int
}{{"", string(raw), 401}, {"quality-viewer", string(raw), 403}, {"quality-admin", strings.TrimSuffix(string(raw), "}") + `,"reviewer":"forged"}`, 400}, {"quality-admin", `null`, 400}, {"quality-admin", string(raw) + `{}`, 400}, {"quality-admin", `{"nonce":"a","nonce":"b"}`, 400}} {
w = qualityRequest(h, "POST", path, tc.token, tc.body)
if w.Code != tc.status {
t.Fatal("review boundary failed", w.Code, tc.status)
}
}
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "quality-admin"})
r.Header.Set("X-Governance-Action", "qa-quality")
r.Header.Set("Sec-Fetch-Site", "cross-site")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("cross-site review accepted")
}
w = qualityRequest(h, "GET", path+"/invalid", "quality-admin", "")
if w.Code != 404 {
t.Fatal("unknown endpoint silently accepted")
}
}
func TestQAQualityReviewRejectsMissingRevisionAndNestedNull(t *testing.T) {
h, _, source := qualityTestHandler(t)
e, _ := qaopsquality.EvidenceFor(source.record)
q := qaopsquality.SaveRequest{InputHash: e.Hash, InputEpoch: 1, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "pending", Correctness: "pending"}, Reason: "示例复核"}
raw, _ := json.Marshal(q)
for _, body := range []string{strings.Replace(string(raw), `"base_revision":0,`, "", 1), strings.Replace(string(raw), `"base_revision":0`, `"base_revision":null`, 1), strings.Replace(string(raw), `"product_category":""`, `"product_category":null`, 1)} {
w := qualityRequest(h, "POST", "/api/v1/qa-ops/records/fixture-turn/reviews", "quality-admin", body)
if w.Code != 400 {
t.Fatal("missing/null version or field silently became a default", w.Code)
}
}
}
+34
View File
@@ -19,6 +19,7 @@ import (
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/monitoring"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/qaopsorg"
)
type JobStore interface {
@@ -101,6 +102,14 @@ type Store interface {
}
type Config struct {
QAQuality QAQualityStore
QAOrgSync QAOrgSyncStore
QAOrgSyncEnabled bool
QAOrg QAOrgStore
QAOps QAOpsReader
QAOpsTenantKey string
QAOpsEnabled bool
QAOpsProblem string
Store Store
Monitor MonitorProvider
InternalRoutingKnowledgeBaseID string
@@ -167,6 +176,27 @@ func NewHandler(config Config) (http.Handler, error) {
config.NewID = newID
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/qa-ops/quality/query", func(w http.ResponseWriter, r *http.Request) { qaQualityQuery(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/records/{id}/reviews", func(w http.ResponseWriter, r *http.Request) { qaQualityReview(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/records/{id}/quality/history", func(w http.ResponseWriter, r *http.Request) { qaQualityHistory(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/records/{id}/quality/inputs/{epoch}", func(w http.ResponseWriter, r *http.Request) { qaQualitySnapshot(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/usage/people", func(w http.ResponseWriter, r *http.Request) { qaMonthlyPeople(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/usage", func(w http.ResponseWriter, r *http.Request) { qaOpsUsage(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/organization/sync", func(w http.ResponseWriter, r *http.Request) { qaOrganizationSync(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/organization/sync", func(w http.ResponseWriter, r *http.Request) { qaOrganizationSync(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/organization", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/organization/preview", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
mux.HandleFunc("PUT /api/v1/qa-ops/organization/regions", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) {
qaOpsStatus(w, r, config)
})
mux.HandleFunc("GET /api/v1/qa-ops/records", func(w http.ResponseWriter, r *http.Request) {
listQAOpsRecords(w, r, config)
})
mux.HandleFunc("GET /api/v1/qa-ops/records/{id}", func(w http.ResponseWriter, r *http.Request) {
getQAOpsRecord(w, r, config)
})
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
@@ -539,6 +569,10 @@ func createJob(w http.ResponseWriter, r *http.Request, config Config) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON request"})
return
}
if strings.HasPrefix(input.Type, qaopsorg.SyncJobPrefix) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "通讯录同步任务只能从问答运营的管理员入口创建"})
return
}
if strings.TrimSpace(input.Type) == "" || len(input.Type) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type is required and must not exceed 100 characters"})
return
+17 -1
View File
@@ -225,10 +225,26 @@ func versionHistorySync(w http.ResponseWriter, r *http.Request, config Config) {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "构造同步任务失败")
return
}
if queue, ok := config.Store.(interface {
EnqueueVersionHistorySync(context.Context, string, string, string, time.Time) (*jobs.Job, error)
}); ok {
target := ""
info := version.Snapshot()
if info.IdentityAvailable && info.CommitSHA != nil {
target = *info.CommitSHA
}
job, e := queue.EnqueueVersionHistorySync(r.Context(), config.VersionHistoryRepo, triggeredBy, target, now)
if e != nil || job == nil {
versionHistoryError(w, 500, "version_history_unavailable", "入队同步任务失败")
return
}
writeJSON(w, 202, map[string]any{"job_id": job.ID, "status": job.State})
return
}
requestedID := fmt.Sprintf("version-history-sync-%d", now.UnixNano())
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{ID: requestedID,
Type: VersionHistoryJobType, Payload: payload, AvailableAt: now, MaxAttempts: 3,
IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo}, now)
IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo + ":" + requestedID}, now)
if err != nil || job == nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "入队同步任务失败")
return
+1 -1
View File
@@ -237,7 +237,7 @@ func TestVersionHistorySyncRequiresAdminAndEnqueuesOnce(t *testing.T) {
if recorder.Code != http.StatusAccepted {
t.Fatalf("首次同步应 202,得到 %d(%s)", recorder.Code, recorder.Body.String())
}
if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance" {
if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance:"+queue.enqueued.ID {
t.Fatalf("入队参数不对:%+v", queue.enqueued)
}
if queue.enqueued.MaxAttempts != 3 || queue.enqueued.AvailableAt != now {
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS qa_ops_organization_state;
DROP TABLE IF EXISTS qa_ops_region_revisions;
DROP TABLE IF EXISTS qa_ops_directory_snapshots;
@@ -0,0 +1,26 @@
CREATE TABLE qa_ops_directory_snapshots (
tenant_key TEXT NOT NULL,
id TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('weekly_report_snapshot','weekly_report_live')),
synced_at TIMESTAMPTZ NOT NULL,
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload JSONB NOT NULL CHECK (jsonb_typeof(payload)='object'),
PRIMARY KEY (tenant_key,id)
);
CREATE TABLE qa_ops_region_revisions (
tenant_key TEXT NOT NULL,
id TEXT NOT NULL,
directory_id TEXT NOT NULL,
rules JSONB NOT NULL CHECK (jsonb_typeof(rules)='array'),
updated_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_key,id),
FOREIGN KEY (tenant_key,directory_id) REFERENCES qa_ops_directory_snapshots(tenant_key,id)
);
CREATE TABLE qa_ops_organization_state (
tenant_key TEXT PRIMARY KEY,
directory_id TEXT NOT NULL,
revision_id TEXT,
FOREIGN KEY (tenant_key,directory_id) REFERENCES qa_ops_directory_snapshots(tenant_key,id),
FOREIGN KEY (tenant_key,revision_id) REFERENCES qa_ops_region_revisions(tenant_key,id)
);
@@ -0,0 +1,4 @@
DROP TABLE qa_ops_quality_reviews;
DROP TABLE qa_ops_quality_checks;
DROP TABLE qa_ops_quality_inputs;
DROP TABLE qa_ops_quality_records;
@@ -0,0 +1,13 @@
CREATE TABLE qa_ops_quality_records (
tenant_key TEXT NOT NULL,record_id TEXT NOT NULL,observed_at TIMESTAMPTZ,input_hash TEXT NOT NULL DEFAULT '',input_epoch BIGINT NOT NULL DEFAULT 0 CHECK(input_epoch>=0),review_revision BIGINT NOT NULL DEFAULT 0 CHECK(review_revision>=0),PRIMARY KEY(tenant_key,record_id)
);
CREATE TABLE qa_ops_quality_inputs (
tenant_key TEXT NOT NULL,record_id TEXT NOT NULL,input_epoch BIGINT NOT NULL CHECK(input_epoch>0),input_hash TEXT NOT NULL CHECK(input_hash ~ '^[a-f0-9]{64}$'),payload JSONB CHECK(payload IS NULL OR jsonb_typeof(payload)='object'),created_at TIMESTAMPTZ NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,record_id,input_epoch),FOREIGN KEY(tenant_key,record_id) REFERENCES qa_ops_quality_records(tenant_key,record_id)
);
CREATE TABLE qa_ops_quality_checks (
id BIGSERIAL PRIMARY KEY,tenant_key TEXT NOT NULL,record_id TEXT NOT NULL,input_epoch BIGINT NOT NULL,rule_version TEXT NOT NULL,result JSONB NOT NULL CHECK(jsonb_typeof(result)='object'),source JSONB NOT NULL CHECK(jsonb_typeof(source)='object'),created_at TIMESTAMPTZ NOT NULL DEFAULT now(),UNIQUE(tenant_key,record_id,input_epoch,rule_version),FOREIGN KEY(tenant_key,record_id,input_epoch) REFERENCES qa_ops_quality_inputs(tenant_key,record_id,input_epoch)
);
CREATE TABLE qa_ops_quality_reviews (
tenant_key TEXT NOT NULL,record_id TEXT NOT NULL,revision BIGINT NOT NULL CHECK(revision>0),input_epoch BIGINT NOT NULL,input_hash TEXT NOT NULL CHECK(input_hash ~ '^[a-f0-9]{64}$'),fields JSONB NOT NULL CHECK(jsonb_typeof(fields)='object'),reason TEXT NOT NULL CHECK(length(btrim(reason))>0),reviewer TEXT NOT NULL CHECK(length(btrim(reviewer))>0),nonce TEXT NOT NULL,request_hash TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,record_id,revision),UNIQUE(tenant_key,record_id,nonce),FOREIGN KEY(tenant_key,record_id,input_epoch) REFERENCES qa_ops_quality_inputs(tenant_key,record_id,input_epoch)
);
CREATE INDEX idx_qa_quality_checks_history ON qa_ops_quality_checks(tenant_key,record_id,id DESC);
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
if err != nil {
t.Fatalf("loadMigrations() error = %v", err)
}
if len(migrations) != 47 {
t.Fatalf("migration count = %d, want 47", len(migrations))
if len(migrations) != 49 {
t.Fatalf("migration count = %d, want 49", len(migrations))
}
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
t.Fatalf("migration 1 = %#v", migrations[0])
@@ -20,6 +20,12 @@ var ErrOperationRequestConflict = errors.New("operation request conflicts with a
type operationExecutor func(pgx.Tx) (json.RawMessage, error)
// ExecuteOperation lets bounded modules share the durable operation transaction
// instead of caching responses after their business writes have committed.
func (s *Store) ExecuteOperation(ctx context.Context, operationType, operationID, fingerprint string, now time.Time, execute func(pgx.Tx) (json.RawMessage, error)) (json.RawMessage, bool, error) {
return s.executeOperation(ctx, operationType, operationID, fingerprint, now, execute)
}
func (s *Store) RouteCatalogFilesWithOperation(ctx context.Context, request governance.CatalogRouteRequest, operationID string) (governance.CatalogRouteResult, bool, error) {
if err := validateCatalogRouteRequest(request); err != nil {
return governance.CatalogRouteResult{}, false, err
@@ -0,0 +1,63 @@
package postgres
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/versionhistory"
"regexp"
"strings"
"time"
)
func (s *Store) EnqueueVersionHistorySync(ctx context.Context, repo, actor, target string, now time.Time) (*jobs.Job, error) {
if strings.Count(repo, "/") != 1 || strings.TrimSpace(repo) != repo || len(repo) > 200 || actor == "" || len(actor) > 256 || target != "" && !regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(target) {
return nil, errors.New("invalid version synchronization request")
}
tx, e := s.db.Begin(ctx)
if e != nil {
return nil, e
}
defer tx.Rollback(context.Background())
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "version-history:"+repo); e != nil {
return nil, e
}
// Retire exhausted expired owners. Terminal jobs are history, not a perpetual
// idempotency key: a later refresh must be allowed to execute again.
if _, e = tx.Exec(ctx, `UPDATE jobs SET state='failed',lease_owner=NULL,lease_until=NULL,updated_at=$3 WHERE type=$1 AND payload->>'repo_key'=$2 AND state='running' AND lease_until<clock_timestamp() AND attempt>=max_attempts`, versionhistory.JobType, repo, now); e != nil {
return nil, e
}
var active string
e = tx.QueryRow(ctx, `SELECT id FROM jobs WHERE type=$1 AND payload->>'repo_key'=$2 AND state IN ('pending','running','retry') ORDER BY created_at DESC,id DESC LIMIT 1`, versionhistory.JobType, repo).Scan(&active)
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
return nil, e
}
if active != "" {
job, e := scanJob(tx.QueryRow(ctx, `SELECT id,type,payload,state,priority,available_at,lease_owner,lease_until,attempt,max_attempts,last_error,idempotency_key,created_at,updated_at FROM jobs WHERE id=$1`, active))
if e != nil {
return nil, e
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return job, nil
}
var random [16]byte
if _, e = rand.Read(random[:]); e != nil {
return nil, e
}
id := "vh-" + hex.EncodeToString(random[:])
payload, _ := json.Marshal(map[string]string{"repo_key": repo, "triggered_by": actor, "target_sha": target})
job, e := NewStore(tx).Enqueue(ctx, jobs.EnqueueRequest{ID: id, Type: versionhistory.JobType, Payload: payload, AvailableAt: now, MaxAttempts: 3, IdempotencyKey: "version-history:" + repo + ":" + id}, now)
if e != nil {
return nil, e
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return job, nil
}
@@ -0,0 +1,52 @@
package postgres
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/versionhistory"
"net/url"
"os"
"strings"
"testing"
"time"
)
func TestVersionHistorySyncQueueCanRefreshAfterCompletion(t *testing.T) {
dsn := os.Getenv("KG_VERSION_HISTORY_TEST_DSN")
if dsn == "" {
t.Skip("explicit disposable version-history DB required")
}
u, e := url.Parse(dsn)
if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_version_history_test_") {
t.Fatal("refusing shared database")
}
ctx := context.Background()
p, e := pgxpool.New(ctx, dsn)
if e != nil {
t.Fatal(e)
}
defer p.Close()
if e = ApplyMigrations(ctx, p); e != nil {
t.Fatal(e)
}
s := NewStore(p)
one, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("a", 40), time.Now())
if e != nil || one == nil {
t.Fatal("first sync missing", e)
}
same, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("a", 40), time.Now())
if e != nil || same.ID != one.ID {
t.Fatal("pending sync not coalesced", e)
}
claimed, e := s.Claim(ctx, "fixture-worker", []string{versionhistory.JobType}, time.Now(), time.Minute)
if e != nil || claimed == nil {
t.Fatal(e)
}
if e = s.Complete(ctx, claimed.ID, claimed.LeaseOwner, time.Now()); e != nil {
t.Fatal(e)
}
next, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("b", 40), time.Now())
if e != nil || next == nil || next.ID == one.ID || next.State != "pending" {
t.Fatal("completed request was replayed forever instead of starting a new refresh", e)
}
}
+123
View File
@@ -0,0 +1,123 @@
package qaopsorg
import (
"context"
"sort"
)
type PersonAttribution struct {
Name string `json:"name"`
DepartmentIDs []string `json:"-"`
State string `json:"state"`
PersonID string `json:"-"`
Departments []string `json:"departments"`
Region string `json:"region"`
}
type Attribution struct {
Metadata State
people map[string]PersonAttribution
unions map[string]string
}
func (p *Attribution) Members() []PersonAttribution {
result := []PersonAttribution{}
if p == nil {
return result
}
for _, person := range p.people {
result = append(result, person)
}
sort.Slice(result, func(i, j int) bool { return result[i].PersonID < result[j].PersonID })
return result
}
func BuildAttribution(state State, directory Directory) (*Attribution, error) {
p := &Attribution{Metadata: state, people: map[string]PersonAttribution{}, unions: map[string]string{}}
if state.State == "empty" {
return p, nil
}
if state.State != "ready" || state.DirectoryID == "" {
return nil, ErrUnavailable
}
view, err := Preview(directory, state.Rules)
if err != nil {
return nil, ErrUnavailable
}
p.Metadata.View = view
departments := map[string]DepartmentView{}
for _, d := range view.Departments {
departments[d.ID] = d
}
for _, person := range directory.People {
paths, regions := map[string]bool{}, map[string]bool{}
for _, id := range person.DepartmentIDs {
d := departments[id]
paths[d.Path] = true
if d.Region != "" {
regions[d.Region] = true
}
}
item := PersonAttribution{Name: person.Name, DepartmentIDs: append([]string{}, person.DepartmentIDs...), State: "unassigned", PersonID: person.ID, Departments: []string{}}
for path := range paths {
item.Departments = append(item.Departments, path)
}
sort.Strings(item.Departments)
if len(regions) == 1 {
item.State = "matched"
for region := range regions {
item.Region = region
}
} else if len(regions) > 1 {
item.State = "cross_region"
}
p.people[person.ID] = item
if person.UnionID != "" {
if !validID(person.UnionID) {
return nil, ErrUnavailable
}
if _, ok := p.unions[person.UnionID]; ok {
p.unions[person.UnionID] = ""
} else {
p.unions[person.UnionID] = person.ID
}
}
}
return p, nil
}
func (p *Attribution) Resolve(staff, union string) PersonAttribution {
none := PersonAttribution{State: "unmatched", Departments: []string{}}
if p == nil || p.Metadata.State != "ready" {
none.State = "directory_missing"
return none
}
if staff == "" && union == "" {
none.State = "no_identity"
return none
}
byUnion, unionKnown := p.unions[union]
if person, ok := p.people[staff]; ok {
if unionKnown && (byUnion == "" || byUnion != staff) {
none.State = "identity_conflict"
return none
}
return person
}
if unionKnown {
if byUnion == "" {
none.State = "identity_conflict"
return none
}
return p.people[byUnion]
}
return none
}
func (s *Store) GetAttribution(ctx context.Context) (*Attribution, error) {
if !s.available() {
return nil, ErrUnavailable
}
state, directory, err := s.read(ctx, s.pool, false)
if err != nil {
return nil, err
}
return BuildAttribution(state, directory)
}
+46
View File
@@ -0,0 +1,46 @@
package qaopsorg
import (
"context"
"errors"
"testing"
)
func TestAttributionUsesStableIdentityAndKeepsAmbiguitiesExplicit(t *testing.T) {
d := Directory{Departments: []Department{{ID: "root", Name: "fixture"}, {ID: "a", ParentID: "root", Name: "one"}, {ID: "b", ParentID: "root", Name: "two"}}, People: []Person{{ID: "staff-a", UnionID: "union-a", DepartmentIDs: []string{"a", "a"}}, {ID: "staff-cross", UnionID: "union-cross", DepartmentIDs: []string{"a", "b"}}, {ID: "duplicate-one", UnionID: "duplicate", DepartmentIDs: []string{"a"}}, {ID: "duplicate-two", UnionID: "duplicate", DepartmentIDs: []string{"b"}}}}
p, err := BuildAttribution(State{State: "ready", DirectoryID: "d", RevisionID: "r", Rules: []Rule{{DepartmentID: "root", Region: "国内工业"}, {DepartmentID: "b", Region: "APAC"}}}, d)
if err != nil {
t.Fatal(err)
}
for _, key := range [][2]string{{"staff-a", ""}, {"", "union-a"}} {
got := p.Resolve(key[0], key[1])
if got.State != "matched" || got.Region != "国内工业" || len(got.Departments) != 1 || got.PersonID != "staff-a" {
t.Fatalf("stable identity not resolved/deduplicated: %+v", got)
}
}
if got := p.Resolve("staff-cross", ""); got.State != "cross_region" || got.Region != "" || len(got.Departments) != 2 {
t.Fatalf("invented primary region: %+v", got)
}
if got := p.Resolve("", "duplicate"); got.State != "identity_conflict" {
t.Fatalf("duplicate union selected first person: %+v", got)
}
if got := p.Resolve("staff-a", "union-cross"); got.State != "identity_conflict" {
t.Fatalf("contradictory identifiers silently preferred: %+v", got)
}
if got := p.Resolve("absent", ""); got.State != "unmatched" {
t.Fatalf("unknown identity inferred: %+v", got)
}
if got := p.Resolve("", ""); got.State != "no_identity" {
t.Fatalf("anonymous identity inferred: %+v", got)
}
}
func TestAttributionDoesNotTreatMissingDirectoryAsPermissionSuccess(t *testing.T) {
p, err := BuildAttribution(State{State: "empty"}, Directory{})
if err != nil || p.Resolve("staff", "").State != "directory_missing" {
t.Fatal("missing directory not explicit")
}
var store *Store
if _, err = store.GetAttribution(context.Background()); !errors.Is(err, ErrUnavailable) {
t.Fatal("unavailable store returned an empty directory")
}
}
+79
View File
@@ -0,0 +1,79 @@
package qaopsorg
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"time"
)
var ErrDirectorySync = errors.New("directory synchronization failed; active snapshot was not replaced")
type ExecDirectoryRunner struct {
Python string
Script string
RuntimeRoot string
SpoolRoot string
Timeout time.Duration
}
// Only server-owned paths cross this boundary. Credentials are read by the
// existing directory client from its private file, never passed in argv/logs.
func (r ExecDirectoryRunner) Run(ctx context.Context) (Import, error) {
for _, path := range []string{r.Python, r.Script, r.RuntimeRoot, r.SpoolRoot} {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return Import{}, ErrDirectorySync
}
}
if r.Timeout == 0 {
r.Timeout = 20 * time.Minute
}
if r.Timeout < time.Millisecond || r.Timeout > 30*time.Minute {
return Import{}, ErrDirectorySync
}
work, err := os.MkdirTemp(r.SpoolRoot, "qa-directory-")
if err != nil {
return Import{}, ErrDirectorySync
}
defer os.RemoveAll(work)
output := filepath.Join(work, "directory.json")
runCtx, cancel := context.WithTimeout(ctx, r.Timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, r.Python, r.Script, "--runtime-root", r.RuntimeRoot, "--output", output, "--fetch-live-directory")
cmd.Dir = r.RuntimeRoot
cmd.Env = []string{"PYTHONUNBUFFERED=1"}
for _, key := range []string{"PATH", "HOME", "TMPDIR", "LANG", "TZ"} {
if value, ok := os.LookupEnv(key); ok {
cmd.Env = append(cmd.Env, key+"="+value)
}
}
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
cmd.WaitDelay = 3 * time.Second
if err = cmd.Run(); err != nil {
return Import{}, ErrDirectorySync
}
info, err := os.Lstat(output)
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() < 1 || info.Size() > 16<<20 {
return Import{}, ErrDirectorySync
}
file, err := os.Open(output)
if err != nil {
return Import{}, ErrDirectorySync
}
defer file.Close()
decoder := json.NewDecoder(io.LimitReader(file, (16<<20)+1))
decoder.DisallowUnknownFields()
var result Import
if decoder.Decode(&result) != nil || decoder.Decode(&struct{}{}) != io.EOF || !result.Complete || result.Source != "weekly_report_live" || result.SyncedAt.IsZero() || result.SyncedAt.After(time.Now().Add(time.Minute)) {
return Import{}, ErrDirectorySync
}
if _, err = Preview(result.Directory, nil); err != nil {
return Import{}, ErrDirectorySync
}
return result, nil
}
@@ -0,0 +1,98 @@
package qaopsorg
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDirectoryBridgeRegressionSuiteRunsInDefaultCI(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_qa_ops_directory_sync.py", "-v")
cmd.Dir = filepath.Join("..", "..")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("directory bridge regression failed: %v\n%s", err, output)
}
}
func TestDirectoryRunnerParsesPrivateOutputAndRemovesSpool(t *testing.T) {
t.Setenv("DINGTALK_CLIENT_SECRET", "fixture-credential-do-not-forward")
root := t.TempDir()
spool := filepath.Join(root, "spool")
if err := os.Mkdir(spool, 0700); err != nil {
t.Fatal(err)
}
script := filepath.Join(root, "fake-sync.sh")
fixture := `{"source":"weekly_report_live","synced_at":"2026-10-01T00:00:00Z","complete":true,"directory":{"departments":[{"id":"root","parent_id":"","name":"隔离测试部门"}],"people":[{"id":"fixture-user","department_ids":["root"]}]}}`
body := "#!/bin/sh\nif [ -n \"${DINGTALK_CLIENT_SECRET:-}\" ]; then exit 3; fi\nwhile [ \"$#\" -gt 0 ]; do if [ \"$1\" = --output ]; then output=$2; shift; fi; shift; done\numask 077\nprintf '%s' '" + fixture + "' > \"$output\"\n"
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
t.Fatal(err)
}
runner := ExecDirectoryRunner{Python: "/bin/sh", Script: script, RuntimeRoot: root, SpoolRoot: spool, Timeout: time.Second}
result, err := runner.Run(context.Background())
if err != nil || len(result.Directory.People) != 1 {
t.Fatalf("runner failed: %v", err)
}
entries, err := os.ReadDir(spool)
if err != nil || len(entries) != 0 {
t.Fatal("personnel spool leaked")
}
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf 'secret-driver-detail' >&2\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
_, err = runner.Run(context.Background())
if err == nil || strings.Contains(err.Error(), "secret-driver-detail") {
t.Fatal("runner error is missing or leaks child stderr")
}
entries, _ = os.ReadDir(spool)
if len(entries) != 0 {
t.Fatal("failed runner leaked spool")
}
}
func TestDirectoryRunnerRejectsMalformedOrPublicPersonnelOutput(t *testing.T) {
root := t.TempDir()
for _, mode := range []string{"malformed", "public"} {
script := filepath.Join(root, mode+".sh")
body := "#!/bin/sh\numask 077\nwhile [ \"$#\" -gt 0 ]; do if [ \"$1\" = --output ]; then output=$2; shift; fi; shift; done\nprintf '%s' '{not-json}' > \"$output\"\n"
if mode == "public" {
body += "printf '%s' '{\"source\":\"weekly_report_live\",\"synced_at\":\"2026-10-01T00:00:00Z\",\"complete\":true,\"directory\":{\"departments\":[{\"id\":\"root\",\"parent_id\":\"\",\"name\":\"fixture\"}],\"people\":[{\"id\":\"fixture-user\",\"department_ids\":[\"root\"]}]}}' > \"$output\"\nchmod 644 \"$output\"\n"
}
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
t.Fatal(err)
}
runner := ExecDirectoryRunner{Python: "/bin/sh", Script: script, RuntimeRoot: root, SpoolRoot: root, Timeout: time.Second}
if _, err := runner.Run(context.Background()); err == nil {
t.Fatal("invalid output accepted")
}
}
}
func TestDirectoryRunnerDeadlineStopsChildAndCleansPrivateSpool(t *testing.T) {
root := t.TempDir()
spool := filepath.Join(root, "spool")
if err := os.Mkdir(spool, 0700); err != nil {
t.Fatal(err)
}
script := filepath.Join(root, "timeout.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\nexec sleep 30\n"), 0700); err != nil {
t.Fatal(err)
}
runner := ExecDirectoryRunner{Python: "/bin/sh", Script: script, RuntimeRoot: root, SpoolRoot: spool, Timeout: 50 * time.Millisecond}
started := time.Now()
if _, err := runner.Run(context.Background()); err == nil {
t.Fatal("timed-out source reported success")
}
if time.Since(started) > 5*time.Second {
t.Fatal("source process cleanup is unbounded")
}
entries, err := os.ReadDir(spool)
if err != nil || len(entries) != 0 {
t.Fatal("timed-out source retained personnel spool")
}
}
+30
View File
@@ -0,0 +1,30 @@
package qaopsorg
import (
"encoding/json"
"strings"
"testing"
)
func TestMembersKeepNamedAndUnnamedRosterPeopleWithoutRawIdentifiers(t *testing.T) {
d := Directory{Departments: []Department{{ID: "root", Name: "fixture"}}, People: []Person{{ID: "private-a", Name: "<源端姓名>", UnionID: "private-union", DepartmentIDs: []string{"root"}}, {ID: "private-zero", DepartmentIDs: []string{"root"}}}}
p, err := BuildAttribution(State{State: "ready", DirectoryID: "directory"}, d)
if err != nil {
t.Fatal(err)
}
members := p.Members()
if len(members) != 2 {
t.Fatal("zero-use roster member omitted")
}
if p.Resolve("private-a", "").Name != "<源端姓名>" || p.Resolve("private-zero", "").Name != "" {
t.Fatal("display name lost or guessed")
}
raw, _ := json.Marshal(members)
if strings.Contains(string(raw), "private-") || strings.Contains(string(raw), "department_ids") {
t.Fatal("internal identifiers serialized")
}
d.People[0].Name = "bad\x00name"
if _, err = BuildAttribution(State{State: "ready", DirectoryID: "directory"}, d); err == nil {
t.Fatal("invalid display name accepted")
}
}
+148
View File
@@ -0,0 +1,148 @@
package qaopsorg
import (
"errors"
"sort"
"strings"
)
var ErrInvalid = errors.New("invalid organization data or region rules")
var RegionNames = []string{"国内工业", "APAC", "EMEA", "America"}
type Department struct {
ID string `json:"id"`
ParentID string `json:"parent_id"`
Name string `json:"name"`
}
type Person struct {
Name string `json:"name,omitempty"`
ID string `json:"id"`
UnionID string `json:"union_id,omitempty"`
DepartmentIDs []string `json:"department_ids"`
}
type Directory struct {
Departments []Department `json:"departments"`
People []Person `json:"people"`
}
type Rule struct {
DepartmentID string `json:"department_id"`
Region string `json:"region"`
}
type DepartmentView struct {
Department
Path string `json:"path"`
Region string `json:"region"`
Explicit bool `json:"explicit"`
InheritedFrom string `json:"inherited_from"`
}
type PreviewResult struct {
Departments []DepartmentView `json:"departments"`
Regions map[string]int `json:"regions"`
TotalPeople int `json:"total_people"`
UnassignedPeople int `json:"unassigned_people"`
ConflictingPeople int `json:"conflicting_people"`
MissingDepartments []string `json:"missing_departments"`
}
// Identity is department ID. Names and legacy keyword rules never decide ownership.
func Preview(directory Directory, rules []Rule) (PreviewResult, error) {
result := PreviewResult{Departments: []DepartmentView{}, Regions: map[string]int{}, MissingDepartments: []string{}, TotalPeople: len(directory.People)}
if len(directory.Departments) == 0 || len(directory.Departments) > 20000 || len(directory.People) == 0 || len(directory.People) > 100000 || len(rules) > 20000 {
return result, ErrInvalid
}
for _, region := range RegionNames {
result.Regions[region] = 0
}
departments := make(map[string]Department, len(directory.Departments))
for _, d := range directory.Departments {
if !validID(d.ID) || d.Name == "" || len(d.Name) > 1000 || strings.ContainsRune(d.Name, 0) {
return result, ErrInvalid
}
if _, ok := departments[d.ID]; ok {
return result, ErrInvalid
}
departments[d.ID] = d
}
direct := map[string]string{}
for _, rule := range rules {
if !validID(rule.DepartmentID) {
return result, ErrInvalid
}
if _, ok := result.Regions[rule.Region]; !ok {
return result, ErrInvalid
}
if _, ok := direct[rule.DepartmentID]; ok {
return result, ErrInvalid
}
direct[rule.DepartmentID] = rule.Region
if _, ok := departments[rule.DepartmentID]; !ok {
result.MissingDepartments = append(result.MissingDepartments, rule.DepartmentID)
}
}
resolved := map[string]DepartmentView{}
for _, d := range directory.Departments {
seen := map[string]bool{}
chain := []string{}
region, from := "", ""
for cur := d.ID; cur != ""; {
node, ok := departments[cur]
if !ok || seen[cur] || len(chain) > 100 {
return result, ErrInvalid
}
seen[cur] = true
chain = append(chain, node.Name)
if region == "" && direct[cur] != "" {
region, from = direct[cur], cur
}
cur = node.ParentID
}
for left, right := 0, len(chain)-1; left < right; left, right = left+1, right-1 {
chain[left], chain[right] = chain[right], chain[left]
}
view := DepartmentView{Department: d, Path: strings.Join(chain, "/"), Region: region, Explicit: direct[d.ID] != ""}
if from != d.ID {
view.InheritedFrom = from
}
resolved[d.ID] = view
result.Departments = append(result.Departments, view)
}
people := map[string]bool{}
for _, person := range directory.People {
if !validID(person.ID) || people[person.ID] || len(person.DepartmentIDs) > 1000 || len(person.Name) > 1000 || strings.ContainsRune(person.Name, 0) {
return result, ErrInvalid
}
people[person.ID] = true
regions := map[string]bool{}
for _, id := range person.DepartmentIDs {
department, ok := resolved[id]
if !ok {
return result, ErrInvalid
}
if department.Region != "" {
regions[department.Region] = true
}
}
switch len(regions) {
case 0:
result.UnassignedPeople++
case 1:
for region := range regions {
result.Regions[region]++
}
default:
result.ConflictingPeople++
}
}
sort.Slice(result.Departments, func(i, j int) bool {
a, b := result.Departments[i], result.Departments[j]
if a.Path == b.Path {
return a.ID < b.ID
}
return a.Path < b.Path
})
sort.Strings(result.MissingDepartments)
return result, nil
}
func validID(value string) bool {
return value != "" && len(value) <= 256 && strings.TrimSpace(value) == value && !strings.ContainsAny(value, "\x00\r\n")
}
+86
View File
@@ -0,0 +1,86 @@
package qaopsorg
import (
"errors"
"testing"
)
func exampleDirectory() Directory {
return Directory{Departments: []Department{
{ID: "root", Name: "示例公司"},
{ID: "sales", ParentID: "root", Name: "销售"},
{ID: "apac", ParentID: "sales", Name: "业务组"},
{ID: "leaf", ParentID: "apac", Name: "支持"},
{ID: "other", ParentID: "root", Name: "业务组"},
}, People: []Person{
{ID: "p1", DepartmentIDs: []string{"sales"}},
{ID: "p2", DepartmentIDs: []string{"leaf"}},
{ID: "p3", DepartmentIDs: []string{"sales", "leaf"}},
{ID: "p4", DepartmentIDs: []string{"other"}},
}}
}
func TestRegionPreviewUsesNearestExplicitAncestorAndCountsPeopleOnce(t *testing.T) {
view, err := Preview(exampleDirectory(), []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}})
if err != nil {
t.Fatal(err)
}
byID := map[string]DepartmentView{}
for _, d := range view.Departments {
byID[d.ID] = d
}
if got := byID["leaf"]; got.Region != "APAC" || got.InheritedFrom != "apac" || got.Path != "示例公司/销售/业务组/支持" {
t.Fatalf("wrong child inheritance: %+v", got)
}
if !byID["apac"].Explicit || byID["other"].Region != "" {
t.Fatal("explicit choice or unmapped department lost")
}
if view.Regions["国内工业"] != 1 || view.Regions["APAC"] != 1 || view.UnassignedPeople != 1 || view.ConflictingPeople != 1 || view.TotalPeople != 4 {
t.Fatalf("person duplicated or conflict silently resolved: %+v", view)
}
}
func TestRegionPreviewRejectsBrokenDirectoryAndDuplicateAssignments(t *testing.T) {
cases := []struct {
name string
mutate func(*Directory)
rules []Rule
}{
{name: "cycle", mutate: func(d *Directory) { d.Departments[0].ParentID = "leaf" }},
{name: "missing parent", mutate: func(d *Directory) { d.Departments[1].ParentID = "absent" }},
{name: "duplicate department", mutate: func(d *Directory) { d.Departments = append(d.Departments, d.Departments[0]) }},
{name: "duplicate person", mutate: func(d *Directory) { d.People = append(d.People, d.People[0]) }},
{name: "missing membership", mutate: func(d *Directory) { d.People[0].DepartmentIDs = []string{"absent"} }},
{name: "conflicting direct rules", rules: []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "sales", Region: "APAC"}}},
{name: "unknown region", rules: []Rule{{DepartmentID: "sales", Region: "automatic"}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d := exampleDirectory()
if tc.mutate != nil {
tc.mutate(&d)
}
_, err := Preview(d, tc.rules)
if !errors.Is(err, ErrInvalid) {
t.Fatalf("must fail closed, got %v", err)
}
})
}
}
func TestRegionPreviewKeepsMissingRulesAndDoesNotInventPrimaryDepartment(t *testing.T) {
d := exampleDirectory()
d.People = append(d.People, Person{ID: "p5", DepartmentIDs: []string{"apac", "leaf"}})
view, err := Preview(d, []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}, {DepartmentID: "removed", Region: "EMEA"}})
if err != nil {
t.Fatal(err)
}
if len(view.MissingDepartments) != 1 || view.MissingDepartments[0] != "removed" || view.ConflictingPeople != 1 || view.Regions["APAC"] != 2 {
t.Fatalf("lost orphan rule or duplicated same-region membership: %+v", view)
}
d.Departments[2].Name = "改名后的部门"
renamed, err := Preview(d, []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}})
if err != nil || renamed.Regions["APAC"] != 2 {
t.Fatal("stable department ID did not survive rename")
}
}
+233
View File
@@ -0,0 +1,233 @@
package qaopsorg
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"knowledge-governance/internal/jobs"
"sort"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrConflict = errors.New("organization snapshot or region revision changed")
var ErrUnavailable = errors.New("organization store unavailable")
var ErrNoDirectory = errors.New("organization directory has not been imported")
type Import struct {
Source string `json:"source"`
SyncedAt time.Time `json:"synced_at"`
Complete bool `json:"complete"`
Directory Directory `json:"directory"`
}
type SaveRequest struct {
DirectoryID string `json:"directory_id"`
BaseRevision string `json:"base_revision"`
Rules []Rule `json:"rules"`
}
type State struct {
State string `json:"state"`
DirectoryID string `json:"directory_id"`
DirectorySource string `json:"directory_source"`
DirectorySyncedAt *time.Time `json:"directory_synced_at"`
RevisionID string `json:"revision_id"`
UpdatedBy string `json:"updated_by"`
UpdatedAt *time.Time `json:"updated_at"`
Rules []Rule `json:"rules"`
View PreviewResult `json:"view"`
}
type Store struct {
pool *pgxpool.Pool
tenant string
}
func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} }
func (s *Store) available() bool { return s != nil && s.pool != nil && validID(s.tenant) }
type orgQuerier interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
const readStateSQL = `SELECT d.id,d.source,d.synced_at,d.payload,COALESCE(r.id,''),COALESCE(r.rules,'[]'::jsonb),COALESCE(r.updated_by,''),r.created_at
FROM qa_ops_organization_state st JOIN qa_ops_directory_snapshots d ON d.tenant_key=st.tenant_key AND d.id=st.directory_id
LEFT JOIN qa_ops_region_revisions r ON r.tenant_key=st.tenant_key AND r.id=st.revision_id WHERE st.tenant_key=$1`
func (s *Store) read(ctx context.Context, q orgQuerier, lock bool) (State, Directory, error) {
state := State{State: "empty", Rules: []Rule{}, View: PreviewResult{Departments: []DepartmentView{}, Regions: map[string]int{}, MissingDepartments: []string{}}}
for _, r := range RegionNames {
state.View.Regions[r] = 0
}
var directory Directory
query := readStateSQL
if lock {
query += " FOR UPDATE OF st"
}
var payload, rules []byte
err := q.QueryRow(ctx, query, s.tenant).Scan(&state.DirectoryID, &state.DirectorySource, &state.DirectorySyncedAt, &payload, &state.RevisionID, &rules, &state.UpdatedBy, &state.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return state, directory, nil
}
if err != nil || json.Unmarshal(payload, &directory) != nil || json.Unmarshal(rules, &state.Rules) != nil {
return State{}, directory, ErrUnavailable
}
state.View, err = Preview(directory, state.Rules)
if err != nil {
return State{}, directory, ErrUnavailable
}
state.State = "ready"
return state, directory, nil
}
func (s *Store) GetView(ctx context.Context) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
state, _, err := s.read(ctx, s.pool, false)
return state, err
}
func (s *Store) PreviewRules(ctx context.Context, request SaveRequest) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
state, directory, err := s.read(ctx, s.pool, false)
if err != nil {
return State{}, err
}
return applyRequest(state, directory, request)
}
func applyRequest(state State, directory Directory, request SaveRequest) (State, error) {
if state.State != "ready" {
return State{}, ErrNoDirectory
}
if request.DirectoryID != state.DirectoryID || request.BaseRevision != state.RevisionID {
return State{}, ErrConflict
}
view, err := Preview(directory, request.Rules)
if err != nil {
return State{}, err
}
// Missing saved paths are retained for operator review, never silently removed.
old := map[string]string{}
for _, rule := range state.Rules {
old[rule.DepartmentID] = rule.Region
}
for _, missing := range view.MissingDepartments {
for _, rule := range request.Rules {
if rule.DepartmentID == missing && old[missing] != rule.Region {
return State{}, ErrInvalid
}
}
}
state.View = view
state.Rules = append([]Rule{}, request.Rules...)
sort.Slice(state.Rules, func(i, j int) bool { return state.Rules[i].DepartmentID < state.Rules[j].DepartmentID })
return state, nil
}
func (s *Store) SaveRules(ctx context.Context, request SaveRequest, actor string) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
if !validID(actor) {
return State{}, ErrInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return State{}, ErrUnavailable
}
defer func() { _ = tx.Rollback(context.Background()) }()
current, directory, err := s.read(ctx, tx, true)
if err != nil {
return State{}, err
}
next, err := applyRequest(current, directory, request)
if err != nil {
return State{}, err
}
before := append([]Rule{}, current.Rules...)
sort.Slice(before, func(i, j int) bool { return before[i].DepartmentID < before[j].DepartmentID })
oldJSON, _ := json.Marshal(before)
newJSON, _ := json.Marshal(next.Rules)
if current.RevisionID != "" && string(oldJSON) == string(newJSON) {
if tx.Commit(ctx) != nil {
return State{}, ErrUnavailable
}
return current, nil
}
var nonce [16]byte
if _, err = rand.Read(nonce[:]); err != nil {
return State{}, ErrUnavailable
}
next.RevisionID = "reg-" + hex.EncodeToString(nonce[:])
next.UpdatedBy = actor
var at time.Time
if err = tx.QueryRow(ctx, `INSERT INTO qa_ops_region_revisions(tenant_key,id,directory_id,rules,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING created_at`, s.tenant, next.RevisionID, next.DirectoryID, newJSON, actor).Scan(&at); err != nil {
return State{}, ErrUnavailable
}
next.UpdatedAt = &at
if _, err = tx.Exec(ctx, `UPDATE qa_ops_organization_state SET revision_id=$2 WHERE tenant_key=$1`, s.tenant, next.RevisionID); err != nil {
return State{}, ErrUnavailable
}
if err = tx.Commit(ctx); err != nil {
return State{}, ErrUnavailable
}
return next, nil
}
func (s *Store) ReplaceDirectory(ctx context.Context, input Import) (State, error) {
return s.replaceDirectory(ctx, input, nil)
}
func (s *Store) replaceDirectory(ctx context.Context, input Import, lease *jobs.Job) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
if !input.Complete || input.SyncedAt.IsZero() || input.SyncedAt.After(time.Now().Add(time.Minute)) || (input.Source != "weekly_report_snapshot" && input.Source != "weekly_report_live") {
return State{}, ErrInvalid
}
if _, err := Preview(input.Directory, nil); err != nil {
return State{}, err
}
raw, err := json.Marshal(input)
if err != nil || len(raw) > 16<<20 {
return State{}, ErrInvalid
}
sum := sha256.Sum256(raw)
id := "dir-" + hex.EncodeToString(sum[:])
payload, _ := json.Marshal(input.Directory)
tx, err := s.pool.Begin(ctx)
if err != nil {
return State{}, ErrUnavailable
}
defer func() { _ = tx.Rollback(context.Background()) }()
if lease != nil {
if _, err = s.syncLease(ctx, tx, *lease, true); err != nil {
return State{}, err
}
}
if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_directory_snapshots(tenant_key,id,source,synced_at,payload) VALUES($1,$2,$3,$4,$5) ON CONFLICT(tenant_key,id) DO NOTHING`, s.tenant, id, input.Source, input.SyncedAt, payload); err != nil {
return State{}, ErrUnavailable
}
tag, err := tx.Exec(ctx, `INSERT INTO qa_ops_organization_state(tenant_key,directory_id) VALUES($1,$2) ON CONFLICT(tenant_key) DO UPDATE SET directory_id=EXCLUDED.directory_id WHERE qa_ops_organization_state.directory_id=EXCLUDED.directory_id OR (SELECT synced_at FROM qa_ops_directory_snapshots WHERE tenant_key=$1 AND id=qa_ops_organization_state.directory_id)<$3`, s.tenant, id, input.SyncedAt)
if err != nil {
return State{}, ErrUnavailable
}
if tag.RowsAffected() != 1 {
return State{}, ErrConflict
}
if lease != nil {
result, _ := json.Marshal(SyncResult{DirectoryID: id, Departments: len(input.Directory.Departments), People: len(input.Directory.People), SyncedAt: input.SyncedAt})
tag, err = tx.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{result}',$3::jsonb),updated_at=now() WHERE id=$1 AND lease_owner=$2 AND state='running' AND lease_until>clock_timestamp() AND attempt=$4`, lease.ID, lease.LeaseOwner, result, lease.Attempt)
if err != nil {
return State{}, ErrUnavailable
}
if tag.RowsAffected() != 1 {
return State{}, ErrSyncLease
}
}
if err = tx.Commit(ctx); err != nil {
return State{}, ErrUnavailable
}
return s.GetView(ctx)
}
+163
View File
@@ -0,0 +1,163 @@
package qaopsorg_test
import (
"context"
"errors"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/qaopsorg"
)
func TestOrganizationStoreRoundTripAndOptimisticConflict(t *testing.T) {
dsn := os.Getenv("KG_QAOPS_ORG_TEST_DSN")
if dsn == "" {
t.Skip("explicit isolated organization database is not configured")
}
u, err := url.Parse(dsn)
if err != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_org_test_") || os.Getenv("KG_QAOPS_ORG_TEST_ISOLATED") != "1" {
t.Fatal("refusing non-isolated organization database")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal("test database connection failed")
}
defer pool.Close()
if err = postgresstore.ApplyMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
store := qaopsorg.NewStore(pool, "fixture-tenant")
empty, err := store.GetView(ctx)
if err != nil || empty.State != "empty" {
t.Fatalf("empty state must be explicit: %+v %v", empty, err)
}
input := qaopsorg.Import{Source: "weekly_report_snapshot", SyncedAt: time.Date(2026, 9, 27, 14, 3, 44, 0, time.UTC), Complete: true, Directory: qaopsorg.Directory{
Departments: []qaopsorg.Department{{ID: "root", Name: "示例公司"}, {ID: "a", ParentID: "root", Name: "国内组"}, {ID: "b", ParentID: "a", Name: "海外组"}},
People: []qaopsorg.Person{{ID: "u1", DepartmentIDs: []string{"a"}}, {ID: "u2", DepartmentIDs: []string{"b"}}},
}}
imported, err := store.ReplaceDirectory(ctx, input)
if err != nil || imported.DirectoryID == "" || imported.View.TotalPeople != 2 {
t.Fatalf("import failed: %+v %v", imported, err)
}
request := qaopsorg.SaveRequest{DirectoryID: imported.DirectoryID, Rules: []qaopsorg.Rule{{DepartmentID: "a", Region: "国内工业"}, {DepartmentID: "b", Region: "APAC"}}}
preview, err := store.PreviewRules(ctx, request)
if err != nil || preview.View.Regions["APAC"] != 1 {
t.Fatalf("preview failed: %+v %v", preview, err)
}
before, _ := store.GetView(ctx)
if before.RevisionID != "" {
t.Fatal("preview must not persist changes")
}
saved, err := store.SaveRules(ctx, request, "fixture-admin")
if err != nil || saved.RevisionID == "" || saved.UpdatedBy != "fixture-admin" {
t.Fatalf("save failed: %+v %v", saved, err)
}
read, err := store.GetView(ctx)
if err != nil || read.RevisionID != saved.RevisionID || read.View.Regions["国内工业"] != 1 {
t.Fatal("saved rules not consumed by read path")
}
projection, projectionErr := store.GetAttribution(ctx)
if projectionErr != nil || projection.Metadata.DirectoryID != read.DirectoryID || projection.Metadata.RevisionID != read.RevisionID || projection.Resolve("u2", "").Region != "APAC" {
t.Fatal("attribution did not consume the saved directory/region revision")
}
if _, err = store.SaveRules(ctx, request, "stale-admin"); !errors.Is(err, qaopsorg.ErrConflict) {
t.Fatalf("stale update overwrote newer rules: %v", err)
}
request.BaseRevision = saved.RevisionID
again, err := store.SaveRules(ctx, request, "fixture-admin")
if err != nil || again.RevisionID != saved.RevisionID {
t.Fatal("unchanged save must not append duplicate revisions")
}
broken := input
broken.Complete = false
if _, err = store.ReplaceDirectory(ctx, broken); !errors.Is(err, qaopsorg.ErrInvalid) {
t.Fatal("partial sync activated")
}
request.Rules = []qaopsorg.Rule{{DepartmentID: "absent", Region: "EMEA"}}
if _, err = store.SaveRules(ctx, request, "fixture-admin"); !errors.Is(err, qaopsorg.ErrInvalid) {
t.Fatal("invalid rule persisted")
}
after, _ := store.GetView(ctx)
if after.RevisionID != saved.RevisionID {
t.Fatal("failed writes changed active revision")
}
other, _ := qaopsorg.NewStore(pool, "another-tenant").GetView(ctx)
if other.State != "empty" {
t.Fatal("directory leaked across tenants")
}
input.Directory.Departments = input.Directory.Departments[:2]
input.Directory.People = input.Directory.People[:1]
input.SyncedAt = input.SyncedAt.Add(time.Hour)
newer, err := store.ReplaceDirectory(ctx, input)
if err != nil || newer.RevisionID != saved.RevisionID || len(newer.View.MissingDepartments) != 1 {
t.Fatalf("directory refresh deleted rules or hid missing department: %+v %v", newer, err)
}
if _, err = store.PreviewRules(ctx, request); !errors.Is(err, qaopsorg.ErrConflict) {
t.Fatal("preview accepted an obsolete directory snapshot")
}
older := input
start := make(chan struct{})
results := make(chan error, 2)
for _, region := range []string{"APAC", "EMEA"} {
go func(region string) {
<-start
_, failure := store.SaveRules(ctx, qaopsorg.SaveRequest{DirectoryID: newer.DirectoryID, BaseRevision: newer.RevisionID, Rules: []qaopsorg.Rule{{DepartmentID: "a", Region: region}}}, "concurrent-admin")
results <- failure
}(region)
}
close(start)
successes, conflicts := 0, 0
for range 2 {
failure := <-results
if failure == nil {
successes++
} else if errors.Is(failure, qaopsorg.ErrConflict) {
conflicts++
} else {
t.Fatal(failure)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("concurrent stale writes were not serialized: success=%d conflict=%d", successes, conflicts)
}
older.SyncedAt = older.SyncedAt.Add(-2 * time.Hour)
if _, err = store.ReplaceDirectory(ctx, older); !errors.Is(err, qaopsorg.ErrConflict) {
t.Fatalf("older directory rolled back the active roster: %v", err)
}
var revisions int
if err = pool.QueryRow(ctx, "SELECT count(*) FROM qa_ops_region_revisions WHERE tenant_key=$1", "fixture-tenant").Scan(&revisions); err != nil || revisions != 2 {
t.Fatal("revision history changed unexpectedly")
}
// This test exercises migration 48, not whichever unrelated migration is latest.
for {
var latest int64
if pool.QueryRow(ctx, "SELECT max(version) FROM schema_migrations").Scan(&latest) != nil {
t.Fatal("migration version unavailable")
}
if latest <= 48 {
break
}
if postgresstore.RollbackLatestMigration(ctx, pool) != nil {
t.Fatal("could not reach organization migration")
}
}
if err = postgresstore.RollbackLatestMigration(ctx, pool); err != nil {
t.Fatal(err)
}
var removed bool
if err = pool.QueryRow(ctx, "SELECT to_regclass('qa_ops_organization_state') IS NULL AND to_regclass('qa_ops_region_revisions') IS NULL AND to_regclass('qa_ops_directory_snapshots') IS NULL").Scan(&removed); err != nil || !removed {
t.Fatal("organization down migration left residue")
}
if err = postgresstore.ApplyMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
if state, failure := store.GetView(ctx); failure != nil || state.State != "empty" {
t.Fatal("organization migration could not be reapplied")
}
}
+194
View File
@@ -0,0 +1,194 @@
package qaopsorg
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
)
var ErrSyncWorkerUnavailable = errors.New("directory sync worker is not connected")
const SyncJobPrefix = "qaops.directory.sync."
var ErrSyncLease = errors.New("directory sync lease is no longer owned")
type DirectoryRunner interface {
Run(context.Context) (Import, error)
}
type SyncReceipt struct {
JobID string `json:"job_id"`
Deduplicated bool `json:"deduplicated"`
}
type SyncResult struct {
DirectoryID string `json:"directory_id"`
Departments int `json:"departments"`
People int `json:"people"`
SyncedAt time.Time `json:"synced_at"`
}
type SyncJob struct {
ID string `json:"id"`
State string `json:"state"`
Attempt int `json:"attempt"`
MaxAttempts int `json:"max_attempts"`
UpdatedAt time.Time `json:"updated_at"`
ErrorCode string `json:"error_code"`
Result *SyncResult `json:"result"`
}
type SyncStatus struct {
Configured bool `json:"configured"`
WorkerReady bool `json:"worker_ready"`
Job *SyncJob `json:"job"`
}
func (s *Store) SyncJobType() string {
h := sha256.Sum256([]byte(s.tenant))
return SyncJobPrefix + hex.EncodeToString(h[:])
}
const syncWorkerReadySQL = `SELECT EXISTS(SELECT 1 FROM worker_heartbeats WHERE $1=ANY(string_to_array(job_types,',')) AND last_seen_at>clock_timestamp()-interval '2 minutes') OR EXISTS(SELECT 1 FROM jobs WHERE type=$1 AND state='running' AND lease_until>clock_timestamp())`
func (s *Store) EnqueueSync(ctx context.Context, key, actor string) (SyncReceipt, bool, error) {
if !s.available() {
return SyncReceipt{}, false, ErrUnavailable
}
if !validID(key) || len(key) > 128 || !validID(actor) {
return SyncReceipt{}, false, ErrInvalid
}
fp := sha256.Sum256([]byte(actor))
now := time.Now()
raw, replayed, err := postgresstore.NewStore(s.pool).ExecuteOperation(ctx, s.SyncJobType(), key, hex.EncodeToString(fp[:]), now, func(tx pgx.Tx) (json.RawMessage, error) {
if _, e := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", s.SyncJobType()); e != nil {
return nil, ErrUnavailable
}
// A last-attempt process may die without recording failure. A new explicit
// action retires that exhausted lease; it never impersonates a live owner.
if _, e := tx.Exec(ctx, `UPDATE jobs SET state='failed',last_error='directory worker stopped; previous snapshot retained',last_error_code='directory_sync_failed',lease_owner=NULL,lease_until=NULL,updated_at=now() WHERE type=$1 AND state='running' AND lease_until<clock_timestamp() AND attempt>=max_attempts`, s.SyncJobType()); e != nil {
return nil, ErrUnavailable
}
var active string
e := tx.QueryRow(ctx, `SELECT id FROM jobs WHERE type=$1 AND state IN ('pending','running','retry') ORDER BY created_at DESC,id DESC LIMIT 1`, s.SyncJobType()).Scan(&active)
if e == nil {
return json.Marshal(SyncReceipt{JobID: active, Deduplicated: true})
}
if !errors.Is(e, pgx.ErrNoRows) {
return nil, ErrUnavailable
}
var ready bool
if tx.QueryRow(ctx, syncWorkerReadySQL, s.SyncJobType()).Scan(&ready) != nil {
return nil, ErrUnavailable
}
if !ready {
return nil, ErrSyncWorkerUnavailable
}
digest := sha256.Sum256([]byte(s.SyncJobType() + ":" + key))
id := "job-" + hex.EncodeToString(digest[:16])
payload, _ := json.Marshal(map[string]string{"triggered_by": actor})
job, e := postgresstore.NewStore(tx).Enqueue(ctx, jobs.EnqueueRequest{ID: id, Type: s.SyncJobType(), Payload: payload, AvailableAt: now, MaxAttempts: 3, IdempotencyKey: s.SyncJobType() + ":" + key}, now)
if e != nil || job == nil {
return nil, ErrUnavailable
}
return json.Marshal(SyncReceipt{JobID: job.ID})
})
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
return SyncReceipt{}, false, ErrConflict
}
if err != nil {
return SyncReceipt{}, false, err
}
var result SyncReceipt
if json.Unmarshal(raw, &result) != nil || result.JobID == "" {
return SyncReceipt{}, false, ErrUnavailable
}
return result, replayed, nil
}
func (s *Store) GetSyncStatus(ctx context.Context) (SyncStatus, error) {
result := SyncStatus{Configured: true}
if !s.available() {
return SyncStatus{}, ErrUnavailable
}
if s.pool.QueryRow(ctx, syncWorkerReadySQL, s.SyncJobType()).Scan(&result.WorkerReady) != nil {
return SyncStatus{}, ErrUnavailable
}
var job SyncJob
var payload []byte
var expired bool
err := s.pool.QueryRow(ctx, `SELECT id,state,attempt,max_attempts,updated_at,payload,COALESCE(state='running' AND lease_until<clock_timestamp(),false) FROM jobs WHERE type=$1 ORDER BY created_at DESC,id DESC LIMIT 1`, s.SyncJobType()).Scan(&job.ID, &job.State, &job.Attempt, &job.MaxAttempts, &job.UpdatedAt, &payload, &expired)
if errors.Is(err, pgx.ErrNoRows) {
return result, nil
}
if err != nil {
return SyncStatus{}, ErrUnavailable
}
var parsed struct {
Result *SyncResult `json:"result"`
}
if json.Unmarshal(payload, &parsed) != nil {
return SyncStatus{}, ErrUnavailable
}
job.Result = parsed.Result
if expired {
job.State = "retry"
if job.Attempt >= job.MaxAttempts {
job.State = "failed"
}
}
if job.State == "failed" || job.State == "retry" {
job.ErrorCode = "directory_sync_failed"
}
result.Job = &job
return result, nil
}
func (s *Store) syncLease(ctx context.Context, q orgQuerier, job jobs.Job, lock bool) (*SyncResult, error) {
if job.Type != s.SyncJobType() || job.LeaseOwner == "" {
return nil, ErrSyncLease
}
query := `SELECT payload FROM jobs WHERE id=$1 AND type=$2 AND state='running' AND lease_owner=$3 AND attempt=$4 AND lease_until>clock_timestamp() AND EXISTS(SELECT 1 FROM operation_requests op WHERE op.operation_type=jobs.type AND op.result->>'job_id'=jobs.id)`
if lock {
query += " FOR UPDATE"
}
var raw []byte
if q.QueryRow(ctx, query, job.ID, s.SyncJobType(), job.LeaseOwner, job.Attempt).Scan(&raw) != nil {
return nil, ErrSyncLease
}
var payload struct {
Result *SyncResult `json:"result"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil, ErrUnavailable
}
return payload.Result, nil
}
func (s *Store) HandleDirectorySync(ctx context.Context, job jobs.Job, runner DirectoryRunner) error {
if !s.available() || runner == nil {
return ErrDirectorySync
}
result, err := s.syncLease(ctx, s.pool, job, false)
if err != nil {
return err
}
if result != nil {
return nil
}
input, err := runner.Run(ctx)
if err != nil {
return ErrDirectorySync
}
if input.Source != "weekly_report_live" {
return jobs.Permanent("directory_sync_failed", ErrDirectorySync)
}
_, err = s.replaceDirectory(ctx, input, &job)
if err != nil {
if errors.Is(err, ErrSyncLease) {
return err
}
return ErrDirectorySync
}
return nil
}
+244
View File
@@ -0,0 +1,244 @@
package qaopsorg_test
import (
"context"
"encoding/json"
"errors"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/qaopsorg"
)
type syncRunner func(context.Context) (qaopsorg.Import, error)
func (f syncRunner) Run(ctx context.Context) (qaopsorg.Import, error) { return f(ctx) }
func TestDirectorySyncQueueDurableReplayAndLeaseFencing(t *testing.T) {
dsn := os.Getenv("KG_QAOPS_ORG_TEST_DSN")
if dsn == "" {
t.Skip("explicit isolated organization database is not configured")
}
u, err := url.Parse(dsn)
if err != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_org_test_") || os.Getenv("KG_QAOPS_ORG_TEST_ISOLATED") != "1" {
t.Fatal("refusing non-isolated organization database")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal("isolated connection failed")
}
defer pool.Close()
if err = postgresstore.ApplyMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
store := qaopsorg.NewStore(pool, "sync-fixture-tenant")
queue := postgresstore.NewStore(pool)
initial := qaopsorg.Import{Source: "weekly_report_live", Complete: true, SyncedAt: time.Now().Add(-time.Hour), Directory: qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "同步夹具"}}, People: []qaopsorg.Person{{ID: "fixture-person", DepartmentIDs: []string{"root"}}}}}
old, err := store.ReplaceDirectory(ctx, initial)
if err != nil {
t.Fatal(err)
}
saved, err := store.SaveRules(ctx, qaopsorg.SaveRequest{DirectoryID: old.DirectoryID, Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "APAC"}}}, "fixture-admin")
if err != nil {
t.Fatal(err)
}
if _, _, err = store.EnqueueSync(ctx, "op-no-worker", "fixture-admin"); !errors.Is(err, qaopsorg.ErrSyncWorkerUnavailable) {
t.Fatalf("disconnected worker accepted: %v", err)
}
if _, err = queue.Claim(ctx, "fixture-sync-worker", []string{store.SyncJobType()}, time.Now(), time.Minute); err != nil {
t.Fatal(err)
}
var first qaopsorg.SyncReceipt
var created atomic.Int32
var wg sync.WaitGroup
failures := make(chan error, 12)
ids := make(chan string, 12)
for range 12 {
wg.Add(1)
go func() {
defer wg.Done()
r, replay, e := qaopsorg.NewStore(pool, "sync-fixture-tenant").EnqueueSync(ctx, "op-same-action", "fixture-admin")
if e != nil {
failures <- e
return
}
if !replay {
created.Add(1)
}
ids <- r.JobID
}()
}
wg.Wait()
close(failures)
for e := range failures {
t.Fatal(e)
}
close(ids)
for id := range ids {
if first.JobID == "" {
first.JobID = id
}
if id != first.JobID {
t.Fatal("duplicate effect")
}
}
if created.Load() != 1 {
t.Fatal("operation replay was not durable")
}
reused, _, err := store.EnqueueSync(ctx, "op-another-click", "fixture-admin")
if err != nil || !reused.Deduplicated || reused.JobID != first.JobID {
t.Fatal("in-flight clicks did not coalesce")
}
if _, _, err = store.EnqueueSync(ctx, "op-same-action", "another-admin"); !errors.Is(err, qaopsorg.ErrConflict) {
t.Fatal("actor fingerprint conflict ignored")
}
other, _ := qaopsorg.NewStore(pool, "other-sync-tenant").GetSyncStatus(ctx)
if other.Job != nil {
t.Fatal("job leaked across tenants")
}
var n int
if err = pool.QueryRow(ctx, "SELECT count(*) FROM jobs WHERE type=$1", store.SyncJobType()).Scan(&n); err != nil || n != 1 {
t.Fatal("job count must remain one")
}
job, err := queue.Claim(ctx, "worker-before-crash", []string{store.SyncJobType()}, time.Now(), time.Minute)
if err != nil || job == nil {
t.Fatal("job was not durable/claimable")
}
// A different process can reclaim expired work; the old owner must not activate data.
if _, err = pool.Exec(ctx, "UPDATE jobs SET lease_until=now()-interval '1 second' WHERE id=$1", job.ID); err != nil {
t.Fatal(err)
}
reclaimed, err := queue.Claim(ctx, "worker-after-restart", []string{store.SyncJobType()}, time.Now(), time.Minute)
if err != nil || reclaimed == nil || reclaimed.Attempt != 2 {
t.Fatal("expired lease did not resume")
}
newer := initial
newer.SyncedAt = time.Now()
newer.Directory.People = append(append([]qaopsorg.Person{}, initial.Directory.People...), qaopsorg.Person{ID: "fixture-person-2", DepartmentIDs: []string{"root"}})
var fetched atomic.Int32
run := syncRunner(func(context.Context) (qaopsorg.Import, error) { fetched.Add(1); return newer, nil })
if err = store.HandleDirectorySync(ctx, *job, run); err == nil {
t.Fatal("stale owner activated a snapshot")
}
still, _ := store.GetView(ctx)
if still.DirectoryID != old.DirectoryID {
t.Fatal("stale lease changed active data")
}
if err = store.HandleDirectorySync(ctx, *reclaimed, run); err != nil {
t.Fatal(err)
}
count := fetched.Load()
// Crash after committing the snapshot, before queue.Complete: do not fetch twice.
if err = store.HandleDirectorySync(ctx, *reclaimed, run); err != nil || fetched.Load() != count {
t.Fatal("committed source was fetched again")
}
if err = queue.Complete(ctx, reclaimed.ID, reclaimed.LeaseOwner, time.Now()); err != nil {
t.Fatal(err)
}
current, _ := store.GetView(ctx)
if current.View.TotalPeople != 2 || current.RevisionID != saved.RevisionID || current.View.Regions["APAC"] != 2 {
t.Fatal("refresh lost regional rules or failed to activate")
}
status, err := store.GetSyncStatus(ctx)
if err != nil || status.Job == nil || status.Job.State != "completed" || status.Job.Result == nil || status.Job.Result.People != 2 {
t.Fatal("safe status missing durable result")
}
raw, _ := json.Marshal(status)
if strings.Contains(string(raw), "fixture-person") || strings.Contains(string(raw), "lease_owner") {
t.Fatal("personnel/lease details leaked in status")
}
// Failure must preserve the previous complete directory and never store raw exceptions.
failed, _, err := store.EnqueueSync(ctx, "op-fails", "fixture-admin")
if err != nil {
t.Fatal(err)
}
bad, err := queue.Claim(ctx, "worker-failure", []string{store.SyncJobType()}, time.Now(), time.Minute)
if err != nil || bad == nil || bad.ID != failed.JobID {
t.Fatal("failure job was not enqueued")
}
failure := store.HandleDirectorySync(ctx, *bad, syncRunner(func(context.Context) (qaopsorg.Import, error) {
return qaopsorg.Import{}, errors.New("sensitive-fixture-must-not-escape")
}))
if failure == nil || strings.Contains(failure.Error(), "sensitive-fixture") {
t.Fatal("unsafe source exception")
}
if err = queue.Fail(ctx, bad.ID, bad.LeaseOwner, failure.Error(), "directory_sync_failed", time.Now(), time.Now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
kept, _ := store.GetView(ctx)
if kept.DirectoryID != current.DirectoryID || kept.RevisionID != saved.RevisionID {
t.Fatal("failed refresh replaced valid state")
}
late := qaopsorg.NewStore(pool, "late-fence-tenant")
lateOld, err := late.ReplaceDirectory(ctx, initial)
if err != nil {
t.Fatal(err)
}
if _, err = queue.Claim(ctx, "late-worker", []string{late.SyncJobType()}, time.Now(), time.Minute); err != nil {
t.Fatal(err)
}
if _, _, err = late.EnqueueSync(ctx, "op-late-fence", "fixture-admin"); err != nil {
t.Fatal(err)
}
lateJob, err := queue.Claim(ctx, "late-worker", []string{late.SyncJobType()}, time.Now(), time.Minute)
if err != nil || lateJob == nil {
t.Fatal("late-fence job unavailable")
}
lateErr := late.HandleDirectorySync(ctx, *lateJob, syncRunner(func(context.Context) (qaopsorg.Import, error) {
_, failure := pool.Exec(ctx, "UPDATE jobs SET lease_owner='replacement-worker',attempt=attempt+1,lease_until=now()+interval '1 minute' WHERE id=$1", lateJob.ID)
return newer, failure
}))
if lateErr == nil {
t.Fatal("lease lost DURING source fetch was not fenced at commit")
}
lateKept, _ := late.GetView(ctx)
if lateKept.DirectoryID != lateOld.DirectoryID {
t.Fatal("late stale worker activated data")
}
if err = pool.QueryRow(ctx, "SELECT count(*) FROM qa_ops_directory_snapshots WHERE tenant_key=$1", "late-fence-tenant").Scan(&n); err != nil || n != 1 {
t.Fatal("rejected stale snapshot left durable rows")
}
if _, err = pool.Exec(ctx, "UPDATE jobs SET lease_until=now()-interval '1 second' WHERE id=$1", lateJob.ID); err != nil {
t.Fatal(err)
}
interrupted, err := late.GetSyncStatus(ctx)
if err != nil || interrupted.Job == nil || interrupted.Job.State != "retry" {
t.Fatal("interrupted lease must wait for takeover, not claim active source processing")
}
if _, err = pool.Exec(ctx, "UPDATE jobs SET attempt=max_attempts,lease_until=now()-interval '1 second' WHERE id=$1", lateJob.ID); err != nil {
t.Fatal(err)
}
exhausted, err := late.GetSyncStatus(ctx)
if err != nil || exhausted.Job == nil || exhausted.Job.State != "failed" {
t.Fatal("exhausted crashed worker reported running forever")
}
fresh, _, err := late.EnqueueSync(ctx, "op-after-exhausted", "fixture-admin")
if err != nil || fresh.JobID == lateJob.ID {
t.Fatal("explicit retry could not replace an exhausted crashed job")
}
ghost := qaopsorg.NewStore(pool, "unadmitted-tenant")
if _, err = ghost.ReplaceDirectory(ctx, initial); err != nil {
t.Fatal(err)
}
_, err = queue.Enqueue(ctx, jobs.EnqueueRequest{ID: "fixture-unadmitted-job", Type: ghost.SyncJobType(), Payload: json.RawMessage(`{}`), AvailableAt: time.Now(), MaxAttempts: 1, IdempotencyKey: "fixture-unadmitted-job"}, time.Now())
if err != nil {
t.Fatal(err)
}
ghostJob, err := queue.Claim(ctx, "ghost-worker", []string{ghost.SyncJobType()}, time.Now(), time.Minute)
if err != nil || ghostJob == nil {
t.Fatal("forged fixture unavailable")
}
var ghostCalls int
ghostErr := ghost.HandleDirectorySync(ctx, *ghostJob, syncRunner(func(context.Context) (qaopsorg.Import, error) { ghostCalls++; return newer, nil }))
if ghostErr == nil || ghostCalls != 0 {
t.Fatal("unadmitted generic task consumed the directory source")
}
}
+59
View File
@@ -0,0 +1,59 @@
package qaopsorg
import (
"errors"
"os"
"path/filepath"
"time"
)
type SyncSettings struct {
Enabled bool
Tenant string
Runner ExecDirectoryRunner
}
func LoadSyncSettings(getenv func(string) string) (SyncSettings, error) {
var s SyncSettings
enabled := getenv("DOBOTAI_OPS_DIRECTORY_SYNC_ENABLED")
if enabled == "" || enabled == "false" {
return s, nil
}
if enabled != "true" {
return s, errors.New("invalid directory sync enable flag")
}
s.Tenant = getenv("DOBOTAI_OPS_TENANT_KEY")
s.Runner = ExecDirectoryRunner{Python: getenv("DOBOTAI_OPS_DIRECTORY_SYNC_PYTHON"), Script: getenv("DOBOTAI_OPS_DIRECTORY_SYNC_SCRIPT"), RuntimeRoot: getenv("DOBOTAI_OPS_DIRECTORY_SYNC_RUNTIME_ROOT"), SpoolRoot: getenv("DOBOTAI_OPS_DIRECTORY_SYNC_SPOOL_ROOT"), Timeout: 20 * time.Minute}
if value := getenv("DOBOTAI_OPS_DIRECTORY_SYNC_TIMEOUT"); value != "" {
d, err := time.ParseDuration(value)
if err != nil {
return SyncSettings{}, errors.New("invalid directory sync timeout")
}
s.Runner.Timeout = d
}
if !validID(s.Tenant) || s.Runner.Timeout < time.Second || s.Runner.Timeout > 30*time.Minute {
return SyncSettings{}, errors.New("directory sync binding is incomplete")
}
for _, path := range []string{s.Runner.Python, s.Runner.Script, s.Runner.RuntimeRoot, s.Runner.SpoolRoot} {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return SyncSettings{}, errors.New("directory sync requires server-owned absolute paths")
}
}
s.Enabled = true
return s, nil
}
func (s SyncSettings) CheckWorkerRuntime() error {
for _, path := range []string{s.Runner.Python, s.Runner.Script} {
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return ErrSyncWorkerUnavailable
}
}
for _, path := range []string{s.Runner.RuntimeRoot, s.Runner.SpoolRoot} {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return ErrSyncWorkerUnavailable
}
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package qaopsorg
import (
"testing"
"time"
)
func TestDirectorySyncSettingsAreExplicitAndServerOwned(t *testing.T) {
values := map[string]string{}
get := func(k string) string { return values[k] }
off, err := LoadSyncSettings(get)
if err != nil || off.Enabled {
t.Fatal("sync must default off")
}
values["DOBOTAI_OPS_DIRECTORY_SYNC_ENABLED"] = "true"
if _, err = LoadSyncSettings(get); err == nil {
t.Fatal("unbound runtime accepted")
}
values["DOBOTAI_OPS_TENANT_KEY"] = "fixture-tenant"
values["DOBOTAI_OPS_DIRECTORY_SYNC_PYTHON"] = "/usr/bin/python3"
values["DOBOTAI_OPS_DIRECTORY_SYNC_SCRIPT"] = "/app/scripts/qa_ops_directory_sync.py"
values["DOBOTAI_OPS_DIRECTORY_SYNC_RUNTIME_ROOT"] = "/data/weekly-report"
values["DOBOTAI_OPS_DIRECTORY_SYNC_SPOOL_ROOT"] = "/data/private-spool"
ready, err := LoadSyncSettings(get)
if err != nil || !ready.Enabled || ready.Runner.Timeout != 20*time.Minute {
t.Fatal("valid runtime not configured")
}
values["DOBOTAI_OPS_DIRECTORY_SYNC_TIMEOUT"] = "31m"
if _, err = LoadSyncSettings(get); err == nil {
t.Fatal("unbounded source timeout")
}
values["DOBOTAI_OPS_DIRECTORY_SYNC_TIMEOUT"] = "10m"
values["DOBOTAI_OPS_DIRECTORY_SYNC_SCRIPT"] = "relative/script.py"
if _, err = LoadSyncSettings(get); err == nil {
t.Fatal("browser-like relative script accepted")
}
}
+125
View File
@@ -0,0 +1,125 @@
package qaopsquality
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"knowledge-governance/internal/dobotaiops"
"regexp"
"strings"
"unicode/utf8"
)
var hashPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
var noncePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`)
func validText(s string, max int) bool {
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= max && !strings.ContainsRune(s, 0)
}
func validID(s string) bool { return strings.TrimSpace(s) != "" && validText(s, 256) }
func canonical(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return json.RawMessage("null"), nil
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var v any
if decoder.Decode(&v) != nil {
return nil, ErrInvalid
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return nil, ErrInvalid
}
out, err := json.Marshal(v)
return out, err
}
func EvidenceFor(record dobotaiops.Record) (Evidence, error) {
if !validID(record.ID) {
return Evidence{}, ErrInvalid
}
refs, err := canonical(record.References)
if err != nil {
return Evidence{}, ErrInvalid
}
media, err := canonical(record.Media)
if err != nil {
return Evidence{}, ErrInvalid
}
// Keep only assessment inputs, never personnel or mutable feedback/organisation.
payload, err := json.Marshal(struct {
Version string `json:"version"`
ID string `json:"id"`
Status string `json:"status"`
Question string `json:"question"`
Answer string `json:"answer"`
References json.RawMessage `json:"references"`
Media json.RawMessage `json:"media"`
}{"dobotai-2.0-dingtalk-quality-v1", record.ID, record.Status, record.Question, record.Answer, refs, media})
if err != nil || len(payload) > 8<<20 {
return Evidence{}, ErrInvalid
}
sum := sha256.Sum256(payload)
hash := hex.EncodeToString(sum[:])
if record.QualityInputHash != "" {
if !hashPattern.MatchString(record.QualityInputHash) {
return Evidence{}, ErrInvalid
}
hash = record.QualityInputHash
}
return Evidence{Hash: hash, Payload: payload, Record: record, Result: dobotaiops.CheckAnswerCoverage(record.Answer)}, nil
}
func Validate(r SaveRequest) error {
if !hashPattern.MatchString(r.InputHash) || r.InputEpoch < 1 || r.BaseRevision < 0 || !noncePattern.MatchString(r.Nonce) || strings.TrimSpace(r.Reason) == "" || !validText(r.Reason, 4000) {
return ErrInvalid
}
f := r.Fields
validCoverage := false
for _, s := range []string{"covered", "uncovered", "partial", "pending", "excluded"} {
validCoverage = validCoverage || f.Coverage == s
}
if !validCoverage || (f.Correctness != "correct" && f.Correctness != "incorrect" && f.Correctness != "pending") {
return ErrInvalid
}
if !validText(f.ProductCategory, 128) || !validText(f.ProblemType, 128) || !validText(f.RootCause, 2000) || !validText(f.ExclusionReason, 2000) {
return ErrInvalid
}
if f.Correctness == "correct" && f.RootCause != "" || f.Coverage == "excluded" && strings.TrimSpace(f.ExclusionReason) == "" || f.Coverage != "excluded" && f.ExclusionReason != "" {
return ErrInvalid
}
return nil
}
func Project(hash string, epoch, revision int64, automatic Check, review *Review) View {
view := View{InputHash: hash, InputEpoch: epoch, Revision: revision, State: "unreviewed", Automatic: automatic, LatestReview: review, Effective: Fields{Coverage: automatic.Result.Verdict}}
if review == nil {
return view
}
view.State = "stale"
if review.InputEpoch == epoch && review.InputHash == hash {
view.State = "reviewed"
view.Effective = review.Fields
}
return view
}
func Summarize(views map[string]View) Summary {
result := Summary{Total: len(views), Rates: ComputeRates(views)}
for _, v := range views {
switch v.State {
case "reviewed":
result.Reviewed++
if v.Effective.Coverage == "pending" || v.Effective.Correctness == "pending" {
result.PendingConfirmation++
}
case "stale":
result.Stale++
default:
result.Unreviewed++
}
if v.Automatic.Result.Verdict == "uncovered" {
result.RuleUncovered++
}
}
return result
}
+99
View File
@@ -0,0 +1,99 @@
package qaopsquality
import (
"encoding/json"
"errors"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/dobotaiops"
"time"
)
var ErrInvalid = errors.New("invalid quality request")
var ErrConflict = errors.New("quality input or review revision changed")
var ErrUnavailable = errors.New("quality store unavailable")
var ErrNotFound = errors.New("quality input snapshot not found")
var ErrSnapshotMissing = errors.New("only quality metadata was observed for this input")
type Fields struct {
Coverage string `json:"coverage"`
Correctness string `json:"correctness"`
ProductCategory string `json:"product_category"`
ProblemType string `json:"problem_type"`
RootCause string `json:"root_cause"`
ExclusionReason string `json:"exclusion_reason"`
}
type SaveRequest struct {
InputHash string `json:"input_hash"`
InputEpoch int64 `json:"input_epoch"`
BaseRevision int64 `json:"base_revision"`
Nonce string `json:"nonce"`
Fields Fields `json:"fields"`
Reason string `json:"reason"`
}
type Review struct {
Revision int64 `json:"revision"`
InputEpoch int64 `json:"input_epoch"`
InputHash string `json:"input_hash"`
Fields Fields `json:"fields"`
Reason string `json:"reason"`
Reviewer string `json:"reviewer"`
CreatedAt time.Time `json:"created_at"`
}
type Check struct {
InputAvailable bool `json:"input_available"`
ID int64 `json:"id"`
InputEpoch int64 `json:"input_epoch"`
InputHash string `json:"input_hash"`
Result dobotaiops.CoverageCheck `json:"result"`
Source dobotaiops.SourceCoverage `json:"source"`
CreatedAt time.Time `json:"created_at"`
}
type View struct {
InputHash string `json:"input_hash"`
InputEpoch int64 `json:"input_epoch"`
Revision int64 `json:"revision"`
State string `json:"state"`
Automatic Check `json:"automatic"`
LatestReview *Review `json:"latest_review"`
Effective Fields `json:"effective"`
}
type History struct {
Reviews []Review `json:"reviews"`
Checks []Check `json:"checks"`
NextReview int64 `json:"next_review"`
NextCheck int64 `json:"next_check"`
}
type Summary struct {
Rates Rates `json:"rates"`
Total int `json:"total"`
Reviewed int `json:"reviewed"`
Unreviewed int `json:"unreviewed"`
Stale int `json:"stale"`
PendingConfirmation int `json:"pending_confirmation"`
RuleUncovered int `json:"rule_uncovered"`
}
type Receipt struct {
View View `json:"view"`
SavedRevision int64 `json:"saved_revision"`
Replayed bool `json:"replayed"`
}
type Evidence struct {
Result dobotaiops.CoverageCheck
Hash string
Payload json.RawMessage
Record dobotaiops.Record
}
type Snapshot struct {
InputEpoch int64 `json:"input_epoch"`
InputHash string `json:"input_hash"`
Payload json.RawMessage `json:"payload"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
pool *pgxpool.Pool
tenant string
}
func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} }
+73
View File
@@ -0,0 +1,73 @@
package qaopsquality
import (
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"strings"
"testing"
)
func TestEvidenceBindsQualityInputsNotPeopleFeedbackOrJSONKeyOrder(t *testing.T) {
record := dobotaiops.Record{ID: "fixture-turn", Status: "succeeded", Question: "示例问题", Answer: "有暂无已发布但存在引用", References: json.RawMessage(`[{"b":2,"a":1}]`), Media: json.RawMessage(`[]`)}
a, err := EvidenceFor(record)
if err != nil || len(a.Hash) != 64 || !strings.Contains(string(a.Payload), record.Answer) {
t.Fatal("quality inputs not snapshotted")
}
record.Asker = "another-person"
record.Department = "changed"
record.Feedback.Up = 10
record.References = json.RawMessage(`[{"a":1,"b":2}]`)
b, _ := EvidenceFor(record)
if b.Hash != a.Hash {
t.Fatal("irrelevant identity/feedback or JSON object ordering invalidated review")
}
record.Answer = "原答案变更"
b, _ = EvidenceFor(record)
if b.Hash == a.Hash {
t.Fatal("answer change undetected")
}
}
func TestHumanProjectionKeepsRuleAndStalesObservedInputChanges(t *testing.T) {
automatic := Check{Result: dobotaiops.CheckAnswerCoverage("暂无已发布"), InputEpoch: 2, InputHash: strings.Repeat("a", 64)}
review := &Review{InputEpoch: 1, InputHash: automatic.InputHash, Fields: Fields{Coverage: "covered", Correctness: "correct", ProductCategory: "MG400"}}
view := Project(automatic.InputHash, 2, 1, automatic, review)
if view.State != "stale" || view.Effective.Coverage != "uncovered" || view.Effective.Correctness != "" {
t.Fatal("old conclusion remained effective after input epoch changed")
}
review.InputEpoch = 2
view = Project(automatic.InputHash, 2, 1, automatic, review)
if view.State != "reviewed" || view.Effective.Coverage != "covered" || view.Automatic.Result.Verdict != "uncovered" {
t.Fatal("human and immutable rule not separated")
}
summary := Summarize(map[string]View{"a": view, "b": Project(automatic.InputHash, 3, 1, automatic, review), "c": Project(automatic.InputHash, 1, 0, automatic, nil)})
if summary.Total != 3 || summary.Reviewed != 1 || summary.Stale != 1 || summary.Unreviewed != 1 || summary.PendingConfirmation != 0 {
t.Fatal("quality counts invent rates or unknown judgments")
}
}
func TestReviewRequiresExplicitOutcomesAndEvidence(t *testing.T) {
valid := SaveRequest{InputHash: strings.Repeat("a", 64), InputEpoch: 1, Nonce: strings.Repeat("n", 32), Fields: Fields{Coverage: "uncovered", Correctness: "pending"}, Reason: "人工核对引用与答案,证据不足"}
if Validate(valid) != nil {
t.Fatal("valid review rejected")
}
for _, field := range []string{"reason", "hash", "coverage", "correctness", "root", "exclusion"} {
bad := valid
switch field {
case "reason":
bad.Reason = " "
case "hash":
bad.InputHash = "malformed"
case "coverage":
bad.Fields.Coverage = ""
case "correctness":
bad.Fields.Correctness = ""
case "root":
bad.Fields.Correctness = "correct"
bad.Fields.RootCause = "知识缺失"
case "exclusion":
bad.Fields.Coverage = "excluded"
}
if Validate(bad) == nil {
t.Fatal("invalid review accepted", field)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package qaopsquality
type Rate struct {
Numerator int `json:"numerator"`
Denominator int `json:"denominator"`
Percent *float64 `json:"percent"`
}
type RateBreakdown struct {
Covered int `json:"covered"`
Uncovered int `json:"uncovered"`
Partial int `json:"partial"`
Pending int `json:"pending"`
Unassessed int `json:"unassessed"`
Excluded int `json:"excluded"`
Stale int `json:"stale"`
CoveredCorrect int `json:"covered_correct"`
CoveredIncorrect int `json:"covered_incorrect"`
CoveredPending int `json:"covered_pending"`
CoveredUnassessed int `json:"covered_unassessed"`
}
type Rates struct {
PolicyVersion string `json:"policy_version"`
Coverage Rate `json:"coverage"`
Correctness Rate `json:"correctness"`
Breakdown RateBreakdown `json:"breakdown"`
}
func ComputeRates(views map[string]View) Rates {
out := Rates{PolicyVersion: "quality-rates-human-first-v1"}
b := &out.Breakdown
for _, v := range views {
if v.State == "stale" {
b.Stale++
continue
}
switch v.Effective.Coverage {
case "covered":
b.Covered++
switch v.Effective.Correctness {
case "correct":
b.CoveredCorrect++
case "incorrect":
b.CoveredIncorrect++
case "pending":
b.CoveredPending++
default:
b.CoveredUnassessed++
}
case "uncovered":
b.Uncovered++
case "partial":
b.Partial++
case "pending":
b.Pending++
case "excluded":
b.Excluded++
default:
b.Unassessed++
}
}
out.Coverage = makeRate(b.Covered, b.Covered+b.Uncovered+b.Partial)
out.Correctness = makeRate(b.CoveredCorrect, b.CoveredCorrect+b.CoveredIncorrect)
return out
}
func makeRate(numerator, denominator int) Rate {
r := Rate{Numerator: numerator, Denominator: denominator}
if denominator > 0 {
p := 100 * float64(numerator) / float64(denominator)
r.Percent = &p
}
return r
}
+44
View File
@@ -0,0 +1,44 @@
package qaopsquality
import (
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"math"
"strings"
"testing"
)
func TestQualityRatesUseEffectiveCurrentConclusionsAndExplicitDenominators(t *testing.T) {
views := map[string]View{}
for i, fields := range []Fields{{Coverage: "covered", Correctness: "correct"}, {Coverage: "covered", Correctness: "incorrect"}, {Coverage: "covered", Correctness: "pending"}, {Coverage: "uncovered"}, {Coverage: "partial", Correctness: "correct"}, {Coverage: "pending", Correctness: "correct"}, {}, {Coverage: "excluded", Correctness: "pending"}, {Coverage: "covered", Correctness: "correct"}, {Coverage: "covered"}} {
state := "reviewed"
if i == 3 || i == 6 {
state = "unreviewed"
}
if i == 8 {
state = "stale"
}
views[string(rune('a'+i))] = View{State: state, Effective: fields, Automatic: Check{Result: dobotaiops.CheckAnswerCoverage("暂无已发布")}}
}
r := ComputeRates(views)
if r.PolicyVersion != "quality-rates-human-first-v1" || r.Coverage.Numerator != 4 || r.Coverage.Denominator != 6 || r.Correctness.Numerator != 1 || r.Correctness.Denominator != 2 {
t.Fatal("effective decisions, partial or exclusion scopes incorrect", r)
}
if r.Coverage.Percent == nil || math.Abs(*r.Coverage.Percent-100*float64(4)/6) > 1e-10 || r.Correctness.Percent == nil || *r.Correctness.Percent != 50 {
t.Fatal("ratio value disagrees with raw counts")
}
b := r.Breakdown
if b.Covered+b.Uncovered+b.Partial+b.Pending+b.Unassessed+b.Excluded+b.Stale != len(views) || b.CoveredCorrect+b.CoveredIncorrect+b.CoveredPending+b.CoveredUnassessed != b.Covered {
t.Fatal("denominator breakdown not conserved")
}
if got := Summarize(views); got.Rates.Coverage.Denominator != 6 {
t.Fatal("scope summary did not consume the one shared calculation")
}
}
func TestQualityRatesEmptyScopeHasNullPercent(t *testing.T) {
r := ComputeRates(map[string]View{})
raw, _ := json.Marshal(r)
if r.Coverage.Percent != nil || r.Correctness.Percent != nil || strings.Count(string(raw), `"percent":null`) != 2 {
t.Fatal("zero denominator fabricated zero or full rate")
}
}
+322
View File
@@ -0,0 +1,322 @@
package qaopsquality
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/dobotaiops"
"sort"
"time"
)
func (s *Store) Columns(ctx context.Context, keys []dobotaiops.QualityKey) ([]string, error) {
if !s.available() {
return nil, ErrUnavailable
}
if len(keys) > 100000 {
return nil, ErrInvalid
}
for _, key := range keys {
if !validID(key.ID) || !hashPattern.MatchString(key.InputHash) {
return nil, ErrInvalid
}
}
raw, err := json.Marshal(keys)
if err != nil {
return nil, ErrInvalid
}
if len(keys) == 0 {
return []string{}, nil
}
var coverage, correctness, product, problem, root bool
err = s.pool.QueryRow(ctx, `SELECT COALESCE(bool_or(r.fields->>'coverage'<>''),false),COALESCE(bool_or(r.fields->>'correctness'<>''),false),COALESCE(bool_or(r.fields->>'product_category'<>''),false),COALESCE(bool_or(r.fields->>'problem_type'<>''),false),COALESCE(bool_or(r.fields->>'root_cause'<>''),false) FROM jsonb_to_recordset($2::jsonb) k(id text,input_hash text) JOIN qa_ops_quality_records h ON h.tenant_key=$1 AND h.record_id=k.id AND h.input_hash=k.input_hash JOIN qa_ops_quality_reviews r ON r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision AND r.input_epoch=h.input_epoch`, s.tenant, raw).Scan(&coverage, &correctness, &product, &problem, &root)
if err != nil {
return nil, ErrUnavailable
}
out := []string{}
for _, c := range []struct {
key string
yes bool
}{{"coverage", coverage}, {"is_correct", correctness}, {"product_category", product}, {"problem_type", problem}, {"root_cause", root}} {
if c.yes {
out = append(out, c.key)
}
}
return out, nil
}
func (s *Store) Snapshot(ctx context.Context, id string, epoch int64) (Snapshot, error) {
if !s.available() {
return Snapshot{}, ErrUnavailable
}
if !validID(id) || epoch < 1 {
return Snapshot{}, ErrInvalid
}
var value Snapshot
value.InputEpoch = epoch
err := s.pool.QueryRow(ctx, `SELECT input_hash,payload,created_at FROM qa_ops_quality_inputs WHERE tenant_key=$1 AND record_id=$2 AND input_epoch=$3`, s.tenant, id, epoch).Scan(&value.InputHash, &value.Payload, &value.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Snapshot{}, ErrNotFound
}
if err != nil {
return Snapshot{}, ErrUnavailable
}
if len(value.Payload) == 0 {
return Snapshot{}, ErrSnapshotMissing
}
return value, nil
}
func (s *Store) available() bool { return s != nil && s.pool != nil && validID(s.tenant) }
func (s *Store) observeLocked(ctx context.Context, tx pgx.Tx, e Evidence) error {
id := e.Record.ID
if _, err := tx.Exec(ctx, `INSERT INTO qa_ops_quality_records(tenant_key,record_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, s.tenant, id); err != nil {
return ErrUnavailable
}
var previous string
var epoch int64
var observedAt *time.Time
if tx.QueryRow(ctx, `SELECT input_hash,input_epoch,observed_at FROM qa_ops_quality_records WHERE tenant_key=$1 AND record_id=$2 FOR UPDATE`, s.tenant, id).Scan(&previous, &epoch, &observedAt) != nil {
return ErrUnavailable
}
// Source transaction time orders concurrent page/detail/scope observations.
// A late different input is rejected, never projected against a newer answer.
if observedAt != nil && !e.Record.QualityObservedAt.IsZero() && e.Record.QualityObservedAt.Before(*observedAt) && previous != e.Hash {
return ErrConflict
}
if !e.Record.QualityObservedAt.IsZero() {
if _, err := tx.Exec(ctx, `UPDATE qa_ops_quality_records SET observed_at=$3 WHERE tenant_key=$1 AND record_id=$2 AND(observed_at IS NULL OR observed_at<$3)`, s.tenant, id, e.Record.QualityObservedAt); err != nil {
return ErrUnavailable
}
}
var payload any
if len(e.Payload) > 0 {
payload = e.Payload
}
if previous != e.Hash {
epoch++
if _, err := tx.Exec(ctx, `INSERT INTO qa_ops_quality_inputs(tenant_key,record_id,input_epoch,input_hash,payload) VALUES($1,$2,$3,$4,$5)`, s.tenant, id, epoch, e.Hash, payload); err != nil {
return ErrUnavailable
}
tag, err := tx.Exec(ctx, `UPDATE qa_ops_quality_records SET input_hash=$3,input_epoch=$4 WHERE tenant_key=$1 AND record_id=$2`, s.tenant, id, e.Hash, epoch)
if err != nil || tag.RowsAffected() != 1 {
return ErrUnavailable
}
}
if payload != nil {
if _, err := tx.Exec(ctx, `UPDATE qa_ops_quality_inputs SET payload=$4 WHERE tenant_key=$1 AND record_id=$2 AND input_epoch=$3 AND payload IS NULL`, s.tenant, id, epoch, payload); err != nil {
return ErrUnavailable
}
}
automatic, _ := json.Marshal(e.Result)
source, _ := json.Marshal(e.Record.SourceCoverage)
if _, err := tx.Exec(ctx, `INSERT INTO qa_ops_quality_checks(tenant_key,record_id,input_epoch,rule_version,result,source) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(tenant_key,record_id,input_epoch,rule_version) DO NOTHING`, s.tenant, id, epoch, dobotaiops.CoverageRuleVersion, automatic, source); err != nil {
return ErrUnavailable
}
return nil
}
const checkObject = `jsonb_build_object('id',c.id,'input_epoch',c.input_epoch,'input_hash',i.input_hash,'result',c.result,'source',c.source,'created_at',c.created_at,'input_available',i.payload IS NOT NULL)`
const reviewObject = `jsonb_build_object('revision',r.revision,'input_epoch',r.input_epoch,'input_hash',r.input_hash,'fields',r.fields,'reason',r.reason,'reviewer',r.reviewer,'created_at',r.created_at)`
func (s *Store) view(ctx context.Context, tx pgx.Tx, id string) (View, error) {
var hash, checkRaw, reviewRaw string
var epoch, revision int64
err := tx.QueryRow(ctx, `SELECT h.input_hash,h.input_epoch,h.review_revision,`+checkObject+`::text,COALESCE((SELECT `+reviewObject+` FROM qa_ops_quality_reviews r WHERE r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision),'null'::jsonb)::text FROM qa_ops_quality_records h JOIN qa_ops_quality_inputs i ON i.tenant_key=h.tenant_key AND i.record_id=h.record_id AND i.input_epoch=h.input_epoch JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version=$3 WHERE h.tenant_key=$1 AND h.record_id=$2`, s.tenant, id, dobotaiops.CoverageRuleVersion).Scan(&hash, &epoch, &revision, &checkRaw, &reviewRaw)
var automatic Check
var review *Review
if err != nil || json.Unmarshal([]byte(checkRaw), &automatic) != nil || json.Unmarshal([]byte(reviewRaw), &review) != nil {
return View{}, ErrUnavailable
}
return Project(hash, epoch, revision, automatic, review), nil
}
func (s *Store) Observe(ctx context.Context, records []dobotaiops.Record) (map[string]View, error) {
if len(records) > 5000 {
return nil, ErrInvalid
}
items := make([]Evidence, 0, len(records))
for _, record := range records {
e, err := EvidenceFor(record)
if err != nil {
return nil, err
}
items = append(items, e)
}
return s.observeEvidence(ctx, items)
}
func (s *Store) ObserveKeys(ctx context.Context, keys []dobotaiops.QualityKey) (map[string]View, error) {
if len(keys) > 5000 {
return nil, ErrInvalid
}
items := make([]Evidence, 0, len(keys))
for _, key := range keys {
if !validID(key.ID) || !hashPattern.MatchString(key.InputHash) {
return nil, ErrInvalid
}
items = append(items, Evidence{Hash: key.InputHash, Record: dobotaiops.Record{ID: key.ID, SourceCoverage: key.Source, QualityObservedAt: key.ObservedAt}, Result: dobotaiops.CheckMarkerCoverage(key.Marker)})
}
return s.observeEvidence(ctx, items)
}
func (s *Store) observeEvidence(ctx context.Context, items []Evidence) (map[string]View, error) {
if !s.available() {
return nil, ErrUnavailable
}
evidence := map[string]Evidence{}
ids := []string{}
bytes := 0
for _, e := range items {
id := e.Record.ID
if old, ok := evidence[id]; ok {
if old.Hash != e.Hash || old.Result != e.Result {
return nil, ErrInvalid
}
continue
}
evidence[id] = e
ids = append(ids, id)
bytes += len(e.Payload)
if bytes > 32<<20 {
return nil, ErrInvalid
}
}
sort.Strings(ids)
result := map[string]View{}
if len(ids) == 0 {
return result, nil
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, ErrUnavailable
}
defer tx.Rollback(context.Background())
for _, id := range ids {
if err = s.observeLocked(ctx, tx, evidence[id]); err != nil {
return nil, err
}
v, err := s.view(ctx, tx, id)
if err != nil {
return nil, err
}
result[id] = v
}
if tx.Commit(ctx) != nil {
return nil, ErrUnavailable
}
return result, nil
}
func (s *Store) Save(ctx context.Context, record dobotaiops.Record, request SaveRequest, actor string) (Receipt, error) {
if !s.available() {
return Receipt{}, ErrUnavailable
}
if Validate(request) != nil || !validID(actor) {
return Receipt{}, ErrInvalid
}
e, err := EvidenceFor(record)
if err != nil {
return Receipt{}, err
}
payload, _ := json.Marshal(request)
sum := sha256.Sum256(payload)
requestHash := hex.EncodeToString(sum[:])
tx, err := s.pool.Begin(ctx)
if err != nil {
return Receipt{}, ErrUnavailable
}
defer tx.Rollback(context.Background())
if err = s.observeLocked(ctx, tx, e); err != nil {
return Receipt{}, err
}
var existingHash, existingActor string
var savedRevision int64
err = tx.QueryRow(ctx, `SELECT request_hash,reviewer,revision FROM qa_ops_quality_reviews WHERE tenant_key=$1 AND record_id=$2 AND nonce=$3`, s.tenant, record.ID, request.Nonce).Scan(&existingHash, &existingActor, &savedRevision)
replay := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Receipt{}, ErrUnavailable
}
current, err := s.view(ctx, tx, record.ID)
if err != nil {
return Receipt{}, err
}
if replay {
if existingHash != requestHash || existingActor != actor {
return Receipt{}, ErrConflict
}
} else {
if current.InputHash != request.InputHash || current.InputEpoch != request.InputEpoch || current.Revision != request.BaseRevision {
if tx.Commit(ctx) != nil {
return Receipt{}, ErrUnavailable
}
return Receipt{}, ErrConflict
}
savedRevision = current.Revision + 1
fields, _ := json.Marshal(request.Fields)
if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_quality_reviews(tenant_key,record_id,revision,input_epoch,input_hash,fields,reason,reviewer,nonce,request_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, s.tenant, record.ID, savedRevision, current.InputEpoch, current.InputHash, fields, request.Reason, actor, request.Nonce, requestHash); err != nil {
return Receipt{}, ErrUnavailable
}
tag, err := tx.Exec(ctx, `UPDATE qa_ops_quality_records SET review_revision=$3 WHERE tenant_key=$1 AND record_id=$2`, s.tenant, record.ID, savedRevision)
if err != nil || tag.RowsAffected() != 1 {
return Receipt{}, ErrUnavailable
}
current, err = s.view(ctx, tx, record.ID)
if err != nil {
return Receipt{}, err
}
}
if tx.Commit(ctx) != nil {
return Receipt{}, ErrUnavailable
}
return Receipt{View: current, SavedRevision: savedRevision, Replayed: replay}, nil
}
func (s *Store) History(ctx context.Context, id string, reviewBefore, checkBefore int64) (History, error) {
result := History{Reviews: []Review{}, Checks: []Check{}}
if !s.available() {
return result, ErrUnavailable
}
if !validID(id) || reviewBefore < 0 || checkBefore < 0 {
return result, ErrInvalid
}
rows, err := s.pool.Query(ctx, `SELECT `+reviewObject+`::text FROM qa_ops_quality_reviews r WHERE tenant_key=$1 AND record_id=$2 AND ($3::bigint=0 OR revision<$3) ORDER BY revision DESC LIMIT 21`, s.tenant, id, reviewBefore)
if err != nil {
return result, ErrUnavailable
}
for rows.Next() {
var raw string
var value Review
if rows.Scan(&raw) != nil || json.Unmarshal([]byte(raw), &value) != nil {
rows.Close()
return result, ErrUnavailable
}
result.Reviews = append(result.Reviews, value)
}
err = rows.Err()
rows.Close()
if err != nil {
return result, ErrUnavailable
}
if len(result.Reviews) > 20 {
result.Reviews = result.Reviews[:20]
result.NextReview = result.Reviews[19].Revision
}
rows, err = s.pool.Query(ctx, `SELECT `+checkObject+`::text FROM qa_ops_quality_checks c JOIN qa_ops_quality_inputs i USING(tenant_key,record_id,input_epoch) WHERE c.tenant_key=$1 AND c.record_id=$2 AND ($3::bigint=0 OR c.id<$3) ORDER BY c.id DESC LIMIT 21`, s.tenant, id, checkBefore)
if err != nil {
return result, ErrUnavailable
}
defer rows.Close()
for rows.Next() {
var raw string
var value Check
if rows.Scan(&raw) != nil || json.Unmarshal([]byte(raw), &value) != nil {
return result, ErrUnavailable
}
result.Checks = append(result.Checks, value)
}
if rows.Err() != nil {
return result, ErrUnavailable
}
if len(result.Checks) > 20 {
result.Checks = result.Checks[:20]
result.NextCheck = result.Checks[19].ID
}
return result, nil
}
@@ -0,0 +1,219 @@
package qaopsquality_test
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/dobotaiops"
pg "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/qaopsquality"
"net/url"
"os"
"strings"
"testing"
"time"
)
func TestQualityStoreImmutableHistoryReplayConcurrencyAndStaleness(t *testing.T) {
dsn := os.Getenv("KG_QAOPS_QUALITY_TEST_DSN")
if dsn == "" {
t.Skip("explicit disposable quality DB required")
}
u, e := url.Parse(dsn)
if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_quality_test_") || os.Getenv("KG_QAOPS_QUALITY_TEST_ISOLATED") != "1" {
t.Fatal("refusing shared quality database")
}
ctx := context.Background()
pool, e := pgxpool.New(ctx, dsn)
if e != nil {
t.Fatal("fixture connection failed")
}
defer pool.Close()
if e = pg.ApplyMigrations(ctx, pool); e != nil {
t.Fatal(e)
}
store := qaopsquality.NewStore(pool, "fixture-tenant")
record := dobotaiops.Record{ID: "fixture-turn", Status: "succeeded", Question: "示例", Answer: "暂无已发布", AskedAt: time.Now()}
views, e := store.Observe(ctx, []dobotaiops.Record{record})
if e != nil {
t.Fatal("source observation not persisted", e)
}
first := views[record.ID]
again, e := store.Observe(ctx, []dobotaiops.Record{record})
if e != nil || again[record.ID].Automatic.ID != first.Automatic.ID {
t.Fatal("repeated GET duplicated immutable checks")
}
metaKey := dobotaiops.QualityKey{ID: "metadata-only-turn", InputHash: strings.Repeat("f", 64), Marker: true}
meta, me := store.ObserveKeys(ctx, []dobotaiops.QualityKey{metaKey})
if me != nil || meta[metaKey.ID].Automatic.InputAvailable {
t.Fatal("compact scope observation not stored honestly", me)
}
if _, me = store.Snapshot(ctx, metaKey.ID, 1); !errors.Is(me, qaopsquality.ErrSnapshotMissing) {
t.Fatal("metadata-only input fabricated an empty original")
}
full, me := store.Observe(ctx, []dobotaiops.Record{{ID: metaKey.ID, QualityInputHash: metaKey.InputHash, Question: "示例", Answer: "暂无已发布"}})
if me != nil || !full[metaKey.ID].Automatic.InputAvailable || full[metaKey.ID].Automatic.ID != meta[metaKey.ID].Automatic.ID {
t.Fatal("reading full content duplicated/replaced the same check")
}
req := qaopsquality.SaveRequest{InputHash: first.InputHash, InputEpoch: first.InputEpoch, BaseRevision: first.Revision, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "covered", Correctness: "correct", ProductCategory: "MG400", ProblemType: "咨询"}, Reason: "人工核对有效引用"}
saved, e := store.Save(ctx, record, req, "fixture-admin")
if e != nil || saved.View.State != "reviewed" || saved.View.Effective.Coverage != "covered" || saved.View.Automatic.Result.Verdict != "uncovered" || saved.View.LatestReview.Reviewer != "fixture-admin" {
t.Fatal("manual override lost audit/rule separation", e)
}
cols, ce := store.Columns(ctx, []dobotaiops.QualityKey{{ID: record.ID, InputHash: first.InputHash}})
if ce != nil || len(cols) != 4 {
t.Fatal("review fields absent from whole-scope projection", ce)
}
replay, e := store.Save(ctx, record, req, "fixture-admin")
if e != nil || !replay.Replayed || replay.SavedRevision != saved.SavedRevision {
t.Fatal("retry duplicated review")
}
bad := req
bad.Reason = "changed body"
if _, e = store.Save(ctx, record, bad, "fixture-admin"); !errors.Is(e, qaopsquality.ErrConflict) {
t.Fatal("nonce reused with different request")
}
if _, e = store.Save(ctx, record, req, "other-admin"); !errors.Is(e, qaopsquality.ErrConflict) {
t.Fatal("nonce replay changed operator")
}
record.Answer = "新答案"
changed, e := store.Observe(ctx, []dobotaiops.Record{record})
if e != nil || changed[record.ID].State != "stale" || changed[record.ID].Effective.Correctness != "" {
t.Fatal("old manual result silently survived change")
}
if _, e = store.Save(ctx, record, req, "fixture-admin"); e != nil {
t.Fatal("old completed request should replay without a new review")
}
record.Answer = "暂无已发布"
reverted, e := store.Observe(ctx, []dobotaiops.Record{record})
v := reverted[record.ID]
if e != nil || v.InputHash != first.InputHash || v.InputEpoch <= first.InputEpoch || v.State != "stale" {
t.Fatal("answer revert reactivated old judgment")
}
req.InputHash = v.InputHash
req.InputEpoch = v.InputEpoch
req.BaseRevision = v.Revision
start := make(chan struct{})
results := make(chan error, 2)
for _, nonce := range []string{strings.Repeat("a", 32), strings.Repeat("b", 32)} {
go func(nonce string) {
q := req
q.Nonce = nonce
<-start
_, err := store.Save(ctx, record, q, "fixture-admin")
results <- err
}(nonce)
}
close(start)
success, conflict := 0, 0
for range 2 {
e = <-results
if e == nil {
success++
} else if errors.Is(e, qaopsquality.ErrConflict) {
conflict++
} else {
t.Fatal(e)
}
}
if success != 1 || conflict != 1 {
t.Fatal("concurrent reviewers overwrote each other")
}
h, e := store.History(ctx, record.ID, 0, 0)
if e != nil || len(h.Reviews) != 2 || len(h.Checks) != 3 || h.Checks[0].InputEpoch != 3 {
t.Fatal("history missing/replaced", e)
}
oldInput, oe := store.Snapshot(ctx, record.ID, 1)
if oe != nil || oldInput.InputHash != first.InputHash || !strings.Contains(string(oldInput.Payload), "暂无已发布") {
t.Fatal("old assessment evidence not readable")
}
// A slow older source scan must not roll back a newer observed input.
ordered := dobotaiops.Record{ID: "ordered-source-turn", Answer: "旧输入", QualityObservedAt: time.Now().Add(-time.Minute)}
oldEvidence, _ := qaopsquality.EvidenceFor(ordered)
if _, e = store.Observe(ctx, []dobotaiops.Record{ordered}); e != nil {
t.Fatal(e)
}
ordered.Answer = "新输入"
ordered.QualityObservedAt = ordered.QualityObservedAt.Add(time.Second)
newViews, ne := store.Observe(ctx, []dobotaiops.Record{ordered})
if ne != nil {
t.Fatal(ne)
}
newView := newViews[ordered.ID]
if _, e = store.ObserveKeys(ctx, []dobotaiops.QualityKey{{ID: ordered.ID, InputHash: oldEvidence.Hash, ObservedAt: ordered.QualityObservedAt.Add(-time.Second)}}); !errors.Is(e, qaopsquality.ErrConflict) {
t.Fatal("late old source scan rolled back the current input")
}
current, ce := store.Observe(ctx, []dobotaiops.Record{ordered})
if ce != nil || current[ordered.ID].InputEpoch != newView.InputEpoch {
t.Fatal("stale scan created extra input epochs")
}
// Independent cursors must enumerate more than one page without loss.
paging := dobotaiops.Record{ID: "history-paging-turn", Answer: "示例"}
pageSeen := map[int64]bool{}
checkSeen := map[int64]bool{}
for i := 0; i < 23; i++ {
paging.Answer = fmt.Sprintf("示例输入%d", i)
vv, ee := store.Observe(ctx, []dobotaiops.Record{paging})
if ee != nil {
t.Fatal(ee)
}
value := vv[paging.ID]
_, ee = store.Save(ctx, paging, qaopsquality.SaveRequest{InputHash: value.InputHash, InputEpoch: value.InputEpoch, BaseRevision: value.Revision, Nonce: fmt.Sprintf("history_%020d", i), Fields: qaopsquality.Fields{Coverage: "pending", Correctness: "pending"}, Reason: "隔离分页验证"}, "fixture-admin")
if ee != nil {
t.Fatal(ee)
}
}
rc, cc := int64(0), int64(0)
for {
page, ee := store.History(ctx, paging.ID, rc, cc)
if ee != nil {
t.Fatal(ee)
}
for _, r := range page.Reviews {
if pageSeen[r.Revision] {
t.Fatal("duplicate history review")
}
pageSeen[r.Revision] = true
}
for _, c := range page.Checks {
if checkSeen[c.ID] {
t.Fatal("duplicate history check")
}
checkSeen[c.ID] = true
}
rc, cc = page.NextReview, page.NextCheck
if rc == 0 && cc == 0 {
break
}
if rc == 0 || cc == 0 {
t.Fatal("unexpected fixture cursor shape")
}
}
if len(pageSeen) != 23 || len(checkSeen) != 23 {
t.Fatal("history cursor omitted immutable rows")
}
freshPool, fe := pgxpool.New(ctx, dsn)
if fe != nil {
t.Fatal(fe)
}
persisted, fe := qaopsquality.NewStore(freshPool, "fixture-tenant").History(ctx, record.ID, 0, 0)
freshPool.Close()
if fe != nil || len(persisted.Reviews) != 2 || len(persisted.Checks) != 3 {
t.Fatal("history did not survive a new connection pool")
}
// History survives a fresh store/connection, not merely an in-memory cache.
if h, e = qaopsquality.NewStore(pool, "other-tenant").History(ctx, record.ID, 0, 0); e != nil || len(h.Reviews) != 0 || len(h.Checks) != 0 {
t.Fatal("quality history crossed tenants")
}
if e = pg.RollbackLatestMigration(ctx, pool); e != nil {
t.Fatal(e)
}
var gone bool
if pool.QueryRow(ctx, `SELECT to_regclass('qa_ops_quality_records') IS NULL AND to_regclass('qa_ops_quality_inputs') IS NULL AND to_regclass('qa_ops_quality_checks') IS NULL AND to_regclass('qa_ops_quality_reviews') IS NULL`).Scan(&gone) != nil || !gone {
t.Fatal("quality down migration left data tables")
}
if e = pg.ApplyMigrations(ctx, pool); e != nil {
t.Fatal(e)
}
}
+58
View File
@@ -0,0 +1,58 @@
package versionhistory
import (
"context"
"fmt"
"knowledge-governance/internal/governance"
"sort"
"strings"
)
func (s *Syncer) includeDeliveredCommits(ctx context.Context, commits []RawCommit, tags []RawTag) ([]RawCommit, error) {
seen := map[string]bool{}
for _, c := range commits {
seen[c.SHA] = true
}
refs := append([]string{}, s.targetSHAs...)
snapshots := []RawTag{}
for _, tag := range tags {
if tag.Annotated && strings.HasPrefix(tag.Name, "snapshot/v") && governance.SemverValid(strings.TrimPrefix(tag.Name, "snapshot/v")) {
snapshots = append(snapshots, tag)
}
}
sort.Slice(snapshots, func(i, j int) bool {
return governance.CompareSemver(strings.TrimPrefix(snapshots[i].Name, "snapshot/v"), strings.TrimPrefix(snapshots[j].Name, "snapshot/v")) > 0
})
for _, tag := range snapshots {
refs = append(refs, tag.TargetSHA)
}
for _, ref := range refs {
if seen[ref] {
continue
}
if !fullSHA.MatchString(ref) {
return nil, fmt.Errorf("开发快照缺少完整提交身份")
}
extra, total, e := s.fetchCommitsAt(ctx, ref)
if e != nil {
return nil, e
}
if total > 0 && len(extra) != total {
return nil, codeError{code: ErrorCodeCountMismatch, message: "开发快照提交历史不完整"}
}
found := false
for _, c := range extra {
if c.SHA == ref {
found = true
}
if !seen[c.SHA] {
seen[c.SHA] = true
commits = append(commits, c)
}
}
if !found {
return nil, codeError{code: ErrorCodeIncompletePages, message: "开发快照目标没有被取回"}
}
}
return commits, nil
}
@@ -0,0 +1,95 @@
package versionhistory
import (
"context"
"fmt"
"knowledge-governance/internal/governance"
"net/http"
"strings"
"testing"
"time"
)
func TestGiteaSnapshotRefIsExplicitAndNotesArePreserved(t *testing.T) {
server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/commits" {
if r.URL.Query().Get("sha") != shaB {
t.Error("snapshot commit history silently used default branch")
}
fmt.Fprintf(w, `[{"sha":%q,"parents":[],"commit":{"message":"fixture"}}]`, shaB)
return
}
if r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/git/refs/tags" {
fmt.Fprintf(w, `[{"ref":"refs/tags/snapshot/v0.1.1","object":{"type":"tag","sha":%q}}]`, shaT)
return
}
fmt.Fprintf(w, `{"message":"development notes","object":{"type":"commit","sha":%q}}`, shaB)
})
c, _ := NewGiteaClient(validConfig(server.URL))
if _, _, e := c.ListCommitsAtRef(context.Background(), shaB, 1, 50); e != nil {
t.Fatal(e)
}
tags, e := c.ListTags(context.Background())
if e != nil || len(tags) != 1 || tags[0].Message != "development notes" {
t.Fatal("annotated snapshot notes lost", e)
}
}
func TestDevelopmentVersionsHaveTheirOwnChangelogNotes(t *testing.T) {
a, b := shaA, shaB
now := time.Now()
text := "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- unrelated future work\n\n## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n### Fixed\n\n- fix specifically delivered here\n\n## [0.1.0] - 2026-10-13\n\n### Added\n\n- baseline\n"
result := DeriveVersions(DeriveInput{Commits: []RawCommit{{SHA: a, CommittedAt: now}, {SHA: b, Parents: []string{a}, CommittedAt: now}}, Tags: []RawTag{{Name: "v0.1.0", TargetSHA: a, Annotated: true}}, Changelog: func(string) (string, error) { return text, nil }})
for _, v := range result.Records {
if v.CommitSHA == b && (v.ReleaseNotes != "### Fixed\n\n- fix specifically delivered here" || v.RecordKind != governance.RecordKindDevelopment) {
t.Fatalf("development version lacks its own notes: %q", v.ReleaseNotes)
}
}
}
func TestHistoricDevelopmentArchiveBindsExactCommit(t *testing.T) {
archive := "## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n目标提交:" + shaB + "\n\n### Fixed\n\n- historic delivery notes\n"
input := DeriveInput{DeliveryArchive: archive, Commits: []RawCommit{{SHA: shaA}, {SHA: shaB, Parents: []string{shaA}}}, Tags: []RawTag{{Name: "v0.1.0", TargetSHA: shaA, Annotated: true}}, Changelog: func(string) (string, error) { return "## [0.1.0] - 2026-10-13\n\n### Added\n\n- baseline", nil }}
result := DeriveVersions(input)
for _, v := range result.Records {
if v.CommitSHA == shaB && !strings.Contains(v.ReleaseNotes, "historic delivery notes") {
t.Fatal("archived notes missing")
}
}
// Parallel branch histories may legitimately have the same derived number.
input.DeliveryArchive = strings.ReplaceAll(archive, shaB, strings.Repeat("c", 40)) + "\n" + archive
result = DeriveVersions(input)
for _, v := range result.Records {
if v.CommitSHA == shaB && !strings.Contains(v.ReleaseNotes, "historic delivery notes") {
t.Fatal("same-number archive hid notes for exact commit")
}
}
input.DeliveryArchive = strings.ReplaceAll(archive, shaB, strings.Repeat("c", 40))
result = DeriveVersions(input)
for _, v := range result.Records {
if v.CommitSHA == shaB && v.ReleaseNotes != "" {
t.Fatal("different commit reused same-number archive notes")
}
}
}
type snapshotRefSource struct {
fakeSource
other []RawCommit
}
func (s *snapshotRefSource) ListCommitsAtRef(_ context.Context, _ string, page, limit int) ([]RawCommit, int, error) {
if page == 1 {
return s.other, len(s.other), nil
}
return nil, len(s.other), nil
}
func TestSyncIncludesDeliveredSnapshotOutsideMain(t *testing.T) {
a := RawCommit{SHA: shaA}
b := RawCommit{SHA: shaB, Parents: []string{shaA}}
src := &snapshotRefSource{fakeSource: fakeSource{commits: []RawCommit{a}, total: 1, pageSize: 50, head: shaA, tags: []RawTag{{Name: "snapshot/v0.1.1", TargetSHA: shaB, Annotated: true}}}, other: []RawCommit{b, a}}
store := &fakeStore{}
run, e := newTestSyncer(t, src, store).Sync(context.Background(), "fixture", "admin")
if e != nil || len(store.saved) != 1 || len(store.saved[0].records) != 2 || !run.Complete {
t.Fatal("delivered non-main snapshot omitted", e, run.FetchedCount)
}
}
+10 -1
View File
@@ -188,6 +188,12 @@ var errGiteaNotFound = newGiteaError(KindNotFound, http.StatusNotFound, "版本
// ListCommits 读一页提交;同时返回 X-Total-Count(用于完整性对账,不做假 total)。
func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCommit, int, error) {
return c.ListCommitsAtRef(ctx, "main", page, limit)
}
func (c *GiteaClient) ListCommitsAtRef(ctx context.Context, ref string, page, limit int) ([]RawCommit, int, error) {
if ref != "main" && !fullSHA.MatchString(ref) {
return nil, 0, newGiteaError(KindParse, 0, "版本记录:只接受 main 或完整提交 SHA", nil)
}
if limit <= 0 || limit > giteaDefaultPageSize {
limit = giteaDefaultPageSize
}
@@ -196,6 +202,7 @@ func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCo
query.Set("page", fmt.Sprintf("%d", page))
query.Set("stat", "false")
query.Set("verification", "false")
query.Set("sha", ref)
body, header, err := c.get(ctx, c.apiURL("/commits", query))
if err != nil {
return nil, 0, err
@@ -322,7 +329,8 @@ func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) {
return nil, err
}
var annotated struct {
Object struct {
Message string `json:"message"`
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
@@ -333,6 +341,7 @@ func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) {
continue
}
tag.TargetSHA = annotated.Object.SHA
tag.Message = annotated.Message
}
tags = append(tags, tag)
}
+26 -7
View File
@@ -8,6 +8,7 @@ package versionhistory
import (
"sort"
"strings"
"time"
"knowledge-governance/internal/governance"
@@ -25,6 +26,7 @@ type RawCommit struct {
// RawTag 是一个 tag 及其可核验属性;Annotated=false 表示轻量 tag(不算正式版本的证据)。
type RawTag struct {
Message string
Name string
TagSHA string
TargetSHA string
@@ -36,13 +38,14 @@ type ChangelogSource func(sha string) (string, error)
// DeriveInput 是一次换算的全部输入。
type DeriveInput struct {
RepoKey string
CommitURLBase string // 形如 "<origin>/<owner>/<repo>/commit/",由配置拼好,不接受外部传入
TagURLBase string // 形如 "<origin>/<owner>/<repo>/src/tag/";空则不下发 tag 链接
Commits []RawCommit
Tags []RawTag
Changelog ChangelogSource
SnapshotID int64
DeliveryArchive string
RepoKey string
CommitURLBase string // 形如 "<origin>/<owner>/<repo>/commit/",由配置拼好,不接受外部传入
TagURLBase string // 形如 "<origin>/<owner>/<repo>/src/tag/";空则不下发 tag 链接
Commits []RawCommit
Tags []RawTag
Changelog ChangelogSource
SnapshotID int64
}
// DeriveResult 是换算结果。
@@ -192,6 +195,22 @@ func DeriveVersions(in DeriveInput) DeriveResult {
}
if kind == governance.RecordKindRelease {
record.ReleaseNotes = governance.ExtractReleaseNotes(changelog, label)
} else if kind == governance.RecordKindDevelopment {
record.ReleaseNotes = governance.ExtractDevelopmentNotes(changelog, label)
if record.ReleaseNotes == "" {
record.ReleaseNotes = governance.ExtractDevelopmentNotesForCommit(in.DeliveryArchive, label, commit.SHA)
}
if record.ReleaseNotes == "" {
for _, tag := range tagsByTarget[commit.SHA] {
if tag.Annotated && tag.Name == "snapshot/v"+label {
_, body, found := strings.Cut(tag.Message, "\n\n")
if found {
record.ReleaseNotes = strings.TrimSpace(body)
}
break
}
}
}
}
records = append(records, record)
}
+53 -16
View File
@@ -130,22 +130,24 @@ type SyncOutcome struct {
// SyncerConfig 装配同步器。
type SyncerConfig struct {
Source Source
Store SyncStore
RepoKey string
Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md
Now func() time.Time
MaxPages int
Source Source
Store SyncStore
RepoKey string
Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md
Now func() time.Time
MaxPages int
TargetSHAs []string
}
// Syncer 抓取远端 main 可达的全部提交并换算版本记录。
type Syncer struct {
source Source
store SyncStore
repoKey string
changelog string
now func() time.Time
maxPages int
source Source
store SyncStore
repoKey string
changelog string
now func() time.Time
maxPages int
targetSHAs []string
}
const syncerDefaultMaxPages = 200
@@ -170,7 +172,12 @@ func NewSyncer(config SyncerConfig) (*Syncer, error) {
if maxPages <= 0 {
maxPages = syncerDefaultMaxPages
}
return &Syncer{source: config.Source, store: config.Store, repoKey: config.RepoKey,
for _, sha := range config.TargetSHAs {
if !fullSHA.MatchString(sha) {
return nil, errors.New("版本记录:运行身份必须是完整 SHA")
}
}
return &Syncer{targetSHAs: append([]string{}, config.TargetSHAs...), source: config.Source, store: config.Store, repoKey: config.RepoKey,
changelog: changelog, now: now, maxPages: maxPages}, nil
}
@@ -204,9 +211,22 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc
return fail(err)
}
commits, err = s.includeDeliveredCommits(ctx, commits, tags)
if err != nil {
return fail(err)
}
total = len(commits)
deliveryArchive := ""
if len(s.targetSHAs) > 0 {
deliveryArchive, err = s.source.ReadFile(ctx, s.targetSHAs[0], s.changelog)
if err != nil {
return fail(err)
}
}
derived := DeriveVersions(DeriveInput{RepoKey: s.repoKey, CommitURLBase: s.source.CommitURLBase(),
TagURLBase: s.source.TagURLBase(),
Commits: commits, Tags: tags, SnapshotID: runID,
TagURLBase: s.source.TagURLBase(),
DeliveryArchive: deliveryArchive,
Commits: commits, Tags: tags, SnapshotID: runID,
Changelog: func(sha string) (string, error) {
return s.source.ReadFile(ctx, sha, s.changelog)
}})
@@ -220,6 +240,9 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc
} else {
sourceHead = headSHA(commits) // 读分支失败不致命:退回提交列表最新一条
}
if len(s.targetSHAs) > 0 {
sourceHead = s.targetSHAs[0]
}
outcome := SyncOutcome{SourceHeadSHA: sourceHead, FetchedCount: len(commits),
ExpectedCount: total, Complete: derived.GraphComplete && (total == 0 || len(commits) == total)}
if err := s.store.FinishVersionHistorySync(ctx, runID, outcome, s.now().UTC()); err != nil {
@@ -232,6 +255,9 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc
// fetchAllCommits 逐页抓取并按 SHA 去重;短页即终止(也防住服务端忽略 page 参数)。
func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error) {
return s.fetchCommitsAt(ctx, "")
}
func (s *Syncer) fetchCommitsAt(ctx context.Context, ref string) ([]RawCommit, int, error) {
pageSize := s.source.PageSize()
if pageSize <= 0 {
pageSize = giteaDefaultPageSize
@@ -240,7 +266,18 @@ func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error)
commits := make([]RawCommit, 0, pageSize)
total := 0
for page := 1; page <= s.maxPages; page++ {
pageCommits, pageTotal, err := s.source.ListCommits(ctx, page, pageSize)
var pageCommits []RawCommit
var pageTotal int
var err error
if ref == "" {
pageCommits, pageTotal, err = s.source.ListCommits(ctx, page, pageSize)
} else if source, ok := s.source.(interface {
ListCommitsAtRef(context.Context, string, int, int) ([]RawCommit, int, error)
}); ok {
pageCommits, pageTotal, err = source.ListCommitsAtRef(ctx, ref, page, pageSize)
} else {
return nil, 0, errors.New("版本来源不支持开发快照历史")
}
if err != nil {
return nil, 0, err
}
+3
View File
@@ -356,6 +356,9 @@ def cmd_internal_web_test() -> int:
def cmd_internal_contracts(go: str) -> int:
"""版本契约:结构 + 注入身份必须**真 PASS**(SKIP 判红),
并且**开发构建不许自称正式版**(负例:标成 release ⇒ 必须红)。"""
snapshot_rc = subprocess.run(py("scripts/development_snapshot.py","--self-test"),cwd=ROOT).returncode
if snapshot_rc:
return 1
rc = subprocess.run(py("scripts/check_changelog.py"), cwd=ROOT).returncode
if rc:
return 1
+10 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# 重建并上线 governance api + web(本机 docker build 需要宿主代理 + --network host)
set -u
cd /home/zheromiku/knowledge-governance || exit 1
cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1
[ -z "$(git status --porcelain --untracked-files=normal)" ] || { printf "%s\n" "REFUSED_DIRTY_SOURCE"; exit 1; }
TS=$(date +%Y%m%d-%H%M%S)
PROXY_ARGS=(--build-arg HTTP_PROXY=http://127.0.0.1:7890 --build-arg HTTPS_PROXY=http://127.0.0.1:7890 --build-arg NO_PROXY=localhost,127.0.0.1 --build-arg GOPROXY=https://goproxy.cn,direct)
@@ -123,4 +124,12 @@ if docker exec knowledge-governance-p1-api-1 sh -lc "strings /app/api | grep -q
else
echo "INJECTION_MISSING(产物里没有 $INJECTED —— 页面会显示当前运行未知)"
fi
echo "=== 10. 版本记录同步读回(当前 SHA + 注释快照 + 独立变更说明)"
# Startup queues the read-only history job. A healthy API alone is not proof.
VERIFIED=0
for ((attempt=0;attempt<90;attempt++)); do
if docker exec knowledge-governance-p1-api-1 /app/api --version-record-status; then VERIFIED=1; break; fi
sleep 2
done
[ "$VERIFIED" = "1" ] || { printf '%s\n' 'VERSION_RECORD_NOT_VERIFIED'; exit 1; }
echo "DONE"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Explicit development delivery tags; never creates formal tags or releases."""
import argparse,json,re,subprocess,tempfile
from pathlib import Path
ROOT=Path(__file__).resolve().parent.parent
SHA=re.compile(r'^[a-f0-9]{40}$')
def git(root,*args):
p=subprocess.run(['git',*args],cwd=root,text=True,capture_output=True)
if p.returncode:raise RuntimeError('git operation failed: '+args[0])
return p.stdout.strip()
def notes(text,version,sha=None):
for heading in re.finditer(r'^## 开发快照 snapshot/v'+re.escape(version)+r' - \d{4}-\d{2}-\d{2}\s*$',text,re.M):
body=re.split(r'^##\s',text[heading.end():],maxsplit=1,flags=re.M)[0].strip()
if sha is None or ('目标提交:'+sha) in body.splitlines():return body
return ''
def plan(root,sha,backfill=False):
if not sha or not SHA.fullmatch(sha):raise ValueError('exact 40-character commit SHA required')
if git(root,'cat-file','-t',sha)!='commit':raise ValueError('snapshot target must be a commit')
text=git(root,'show',sha+':CHANGELOG.md');m=re.search(r'^##\s*\[(\d+\.\d+\.\d+)\]',text,re.M)
if not m:raise ValueError('formal baseline missing')
baseline=m.group(1);base='v'+baseline
if git(root,'cat-file','-t','refs/tags/'+base)!='tag':raise ValueError('annotated formal baseline missing')
git(root,'merge-base','--is-ancestor',base,sha)
n=int(git(root,'rev-list','--count',base+'..'+sha));version='.'.join(baseline.split('.')[:2]+[str(n)])
if n<=0:raise ValueError('formal baseline cannot be tagged as a development delivery')
body=notes(text,version)
if not body and backfill:
body=notes(git(root,'show','HEAD:CHANGELOG.md'),version,sha)
if '目标提交:'+sha not in body:raise ValueError('backfilled notes must bind the exact historic SHA')
if not body:raise ValueError('this development version has no independent CHANGELOG entry')
if not re.search(r'^### (Added|Changed|Fixed|Security)\s*$',body,re.M):raise ValueError('snapshot notes have no change category')
return {'commit_sha':sha,'display_version':version,'tag':'snapshot/v'+version,'message':'Development snapshot '+version+'\n\n'+body,'backfilled':bool(backfill)}
def publish(root,p):
# Only explicitly named development refs are written. Existing refs, including
# legacy misnumbered snapshots, are never moved or overwritten.
tag=p['tag'];sha=p['commit_sha'];ref='refs/tags/'+tag
remote=git(root,'ls-remote','origin');pairs=[line.split() for line in remote.splitlines() if line.strip()];refs={name:obj for obj,name in pairs}
if ref in refs:
if refs.get(ref+'^{}')!=sha:raise ValueError('remote snapshot already exists with different or non-annotated identity')
return {'tag':tag,'commit_sha':sha,'state':'already_published'}
reachable=False
for obj,name in pairs:
if name.startswith('refs/heads/') and subprocess.run(['git','merge-base','--is-ancestor',sha,obj],cwd=root,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:reachable=True;break
if not reachable:raise ValueError('snapshot target has not been pushed to a remote branch')
exists=subprocess.run(['git','show-ref','--verify','--quiet',ref],cwd=root).returncode==0
if exists:
if git(root,'cat-file','-t',ref)!='tag' or git(root,'rev-parse',ref+'^{}')!=sha:raise ValueError('local snapshot conflicts with requested identity')
if git(root,'for-each-ref','--format=%(contents)',ref).strip()!=p['message'].strip():raise ValueError('existing snapshot annotation differs; will not overwrite')
else:
with tempfile.NamedTemporaryFile(mode='w',prefix='kg-snapshot-note-',encoding='utf-8') as f:
f.write(p['message']+'\n');f.flush();git(root,'tag','-a',tag,sha,'-F',f.name)
git(root,'push','origin',ref+':'+ref)
readback={line.split()[1]:line.split()[0] for line in git(root,'ls-remote','--tags','origin',ref,ref+'^{}').splitlines()}
if readback.get(ref+'^{}')!=sha or readback.get(ref)!=git(root,'rev-parse',ref):raise RuntimeError('remote snapshot read-back failed')
return {'tag':tag,'commit_sha':sha,'state':'published_and_read_back'}
def self_test():
with tempfile.TemporaryDirectory(prefix='kg-snapshot-test-') as d:
r=Path(d);git(r,'init','-b','main');git(r,'config','user.name','Fixture');git(r,'config','user.email','fixture@example.invalid')
base='# Changelog\n\n## [0.1.0] - 2026-10-13\n\n### Added\n\n- fixture baseline\n'
(r/'CHANGELOG.md').write_text(base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','feat: fixture baseline');git(r,'tag','-a','v0.1.0','-m','fixture')
(r/'CHANGELOG.md').write_text('## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n### Fixed\n\n- specific fixture fix\n\n'+base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','fix: fixture change')
sha=git(r,'rev-parse','HEAD');before=git(r,'tag');p=plan(r,sha)
assert p['tag']=='snapshot/v0.1.1' and 'specific fixture fix' in p['message']
duplicate='## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n目标提交:'+('a'*40)+'\n\n### Fixed\n\n- wrong branch\n\n## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n目标提交:'+sha+'\n\n### Fixed\n\n- exact branch'
assert 'exact branch' in notes(duplicate,'0.1.1',sha) and 'wrong branch' not in notes(duplicate,'0.1.1',sha)
assert before==git(r,'tag'),'planning wrote a tag'
try:plan(r,'a'*7)
except ValueError:pass
else:raise AssertionError('short SHA accepted')
bare=r/'remote.git';git(r,'init','--bare',str(bare));git(r,'remote','add','origin',str(bare));git(r,'push','origin','main')
out=publish(r,p);assert out['state']=='published_and_read_back'
assert git(r,'rev-parse',p['tag']+'^{}')==sha
assert publish(r,p)['state']=='already_published'
wrong=dict(p,commit_sha='a'*40)
try:publish(r,wrong)
except ValueError:pass
else:raise AssertionError('conflicting remote snapshot overwritten')
assert git(r,'tag','--list','v*').splitlines()==['v0.1.0'],'formal tags changed'
print('development snapshot fixture passed; real repository tags untouched')
def main():
a=argparse.ArgumentParser();a.add_argument('--sha');a.add_argument('--backfill',action='store_true');a.add_argument('--publish',action='store_true');a.add_argument('--self-test',action='store_true');o=a.parse_args()
if o.self_test:return self_test()
p=plan(ROOT,o.sha,o.backfill)
print(json.dumps(publish(ROOT,p) if o.publish else p,ensure_ascii=False))
if __name__=='__main__':main()
+177
View File
@@ -0,0 +1,177 @@
"""Read-only bridge to the existing weekly-report contacts fetchers.
The bridge does not execute the weekly report, export spreadsheets, write its
configuration, or infer company/department names. All new data stays in a private
caller-selected JSON file for the governance importer.
"""
import argparse
from datetime import datetime, timezone
import importlib
import json
import logging
import os
from pathlib import Path
import sys
import types
from urllib.request import HTTPRedirectHandler, ProxyHandler, build_opener
sys.dont_write_bytecode = True
class DirectorySyncError(RuntimeError):
pass
def _validate_directory(departments, people):
if not departments or not people or len(departments) > 20000 or len(people) > 100000:
raise DirectorySyncError('directory_integrity_failed')
for identity, department in departments.items():
if identity <= 0 or not department.get('name'):
raise DirectorySyncError('directory_integrity_failed')
seen = set()
current = identity
while current:
if current in seen or current not in departments or len(seen) > 100:
raise DirectorySyncError('directory_integrity_failed')
seen.add(current)
current = int(departments[current].get('parent_id') or 0)
for identity, person in people.items():
name = person.get('name')
if name is not None and (not isinstance(name, str) or len(name.encode('utf-8')) > 1000 or '\x00' in name):
raise DirectorySyncError('directory_integrity_failed')
if not identity or str(person.get('user_id') or '') != str(identity):
raise DirectorySyncError('directory_integrity_failed')
if not person.get('department_ids') or any(int(d) not in departments for d in person['department_ids']):
raise DirectorySyncError('directory_integrity_failed')
def collect_directory(client, fetch_departments, fetch_users, get_detail, logger):
if client.config.dry_run:
raise DirectorySyncError('directory_dry_run_rejected')
departments, denied, failed = fetch_departments(client, logger, None, None)
if denied or failed or not departments:
raise DirectorySyncError('directory_department_fetch_incomplete')
departments = dict(departments)
# Legacy full traversal returns children but omits the starting root. Read
# actual missing ancestors; never manufacture a root or discard parent IDs.
for _ in range(100):
missing = sorted({int(d.get('parent_id') or 0) for d in departments.values()} - set(departments) - {0})
if not missing:
break
for identity in missing:
detail, denied, failed = get_detail(client, logger, identity)
if denied or failed or not detail or int(detail.get('id') or 0) != identity or not detail.get('name'):
raise DirectorySyncError('directory_ancestor_unavailable')
departments[identity] = detail
else:
raise DirectorySyncError('directory_integrity_failed')
people, denied, failed = fetch_users(client, logger, sorted(departments), enable_user_detail=False)
if denied or failed:
raise DirectorySyncError('directory_people_fetch_incomplete')
_validate_directory(departments, people)
return {
'source': 'weekly_report_live',
'synced_at': datetime.now(timezone.utc).isoformat(),
'complete': True,
'directory': {
'departments': [
{'id': str(identity), 'parent_id': str(int(d.get('parent_id') or 0)) if d.get('parent_id') else '', 'name': str(d['name'])}
for identity, d in sorted(departments.items())
],
# List membership provides stable staff IDs; do not reuse the old
# guessed primary department, employment policy or openId fallback.
'people': [
{'id': str(identity), **({'name': p['name']} if p.get('name') else {}), 'union_id': str((p.get('raw_payload') or {}).get('unionid') or (p.get('raw_payload') or {}).get('unionId') or ''), 'department_ids': [str(d) for d in sorted(p['department_ids'])]}
for identity, p in sorted(people.items())
],
},
}
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise DirectorySyncError('directory_redirect_rejected')
def load_existing_runtime(root):
root = Path(root)
if not root.is_absolute() or not (root / 'scripts/dingtalk_sync/client.py').is_file():
raise DirectorySyncError('directory_configuration_invalid')
secret = root / '.env'
info = secret.lstat()
if not secret.is_file() or secret.is_symlink() or info.st_mode & 0o077:
raise DirectorySyncError('directory_configuration_invalid')
package_name = '_qaops_weekly_directory'
package = types.ModuleType(package_name)
package.__path__ = [str(root / 'scripts/dingtalk_sync')]
sys.modules[package_name] = package
client_module = importlib.import_module(package_name + '.client')
dept_module = importlib.import_module(package_name + '.fetch_departments')
people_module = importlib.import_module(package_name + '.fetch_users')
config = client_module.load_sync_config(str(secret), str(root / 'config/dingtalk_sync.yaml'))
if config.api_base_url.rstrip('/') != 'https://oapi.dingtalk.com' or config.dry_run or config.api_mode != 'topapi_v2':
raise DirectorySyncError('directory_configuration_invalid')
if not (1 <= config.timeout_seconds <= 30 and 0 <= config.max_retries <= 3 and 1 <= config.page_size <= 100):
raise DirectorySyncError('directory_configuration_invalid')
# Keep the old transport inside this private module namespace. Suppress its
# potentially sensitive exception logging and refuse redirects/proxies.
logger = logging.getLogger('qa_ops_directory_private')
logger.handlers = [logging.NullHandler()]
logger.propagate = False
opener = build_opener(ProxyHandler({}), _NoRedirect())
setattr(client_module, 'urlopen', opener.open)
class BoundedClient(client_module.DingtalkClient):
def __init__(self):
super().__init__(config, logger=logger)
self.requests = 0
self.pages = set()
def request_json(self, method, path, params=None, json_body=None, retry_on_business_error=True):
self.requests += 1
if self.requests > 10000:
raise DirectorySyncError('directory_request_limit')
if path.endswith('/listsub') or path.endswith('/user/list'):
key = (path, json.dumps(json_body, sort_keys=True))
if key in self.pages:
raise DirectorySyncError('directory_pagination_repeated')
self.pages.add(key)
return super().request_json(method, path, params, json_body, retry_on_business_error)
return BoundedClient(), dept_module.fetch_all_departments, people_module.fetch_users, dept_module._get_department_detail, logger
def main():
parser = argparse.ArgumentParser(description='Fetch a complete read-only directory into private governance JSON; no weekly-report writes')
parser.add_argument('--runtime-root', required=True)
parser.add_argument('--output', required=True)
parser.add_argument('--fetch-live-directory', action='store_true', required=True)
args = parser.parse_args()
output = Path(args.output)
if not output.is_absolute() or output.exists():
raise DirectorySyncError('directory_output_must_be_new_absolute_path')
client, departments, people, detail, logger = load_existing_runtime(args.runtime_root)
print(json.dumps({'stage': 'fetching_live_directory', 'weekly_report_outputs_modified': False}), flush=True)
result = collect_directory(client, departments, people, detail, logger)
raw = json.dumps(result, ensure_ascii=False).encode('utf-8')
if len(raw) > 16 << 20:
raise DirectorySyncError('directory_output_limit')
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
output.unlink(missing_ok=True)
raise
print(json.dumps({'stage': 'complete', 'departments': len(result['directory']['departments']), 'people': len(result['directory']['people']), 'synced_at': result['synced_at'], 'requests': client.requests, 'complete': True, 'employee_identities_logged': False, 'weekly_report_outputs_modified': False}), flush=True)
if __name__ == '__main__':
try:
main()
except Exception as failure:
code = str(failure) if isinstance(failure, DirectorySyncError) else 'directory_sync_failed'
print(json.dumps({'stage': 'failed', 'code': code}), file=sys.stderr)
raise SystemExit(1)
+53
View File
@@ -9,6 +9,59 @@
"⚠️ 文件缺失或解析不了 ⇒ 门禁红:范围声明不出来时,不许默认放行。"
],
"optional": [
{"package":"knowledge-governance/internal/qaopsquality","test":"TestQualityStoreImmutableHistoryReplayConcurrencyAndStaleness","reason":"仅在显式一次性 loopback kg_qaops_quality_test_ 库执行;默认 CI 不连接共享/生产库,隔离 PG 另行实跑"},
{"package": "knowledge-governance/internal/dobotaiops", "test": "TestQARecordInternalIdentityAndWholeFilterKeys", "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"},
{"package": "knowledge-governance/internal/dobotaiops", "test": "TestQAUsageGroupsCountEventsOnceOverTheFullFilter", "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"},
{
"package": "knowledge-governance/internal/qaopsorg",
"test": "TestDirectorySyncQueueDurableReplayAndLeaseFencing",
"reason": "仅显式一次性隔离PG库验证持久化入队、并发重放、进程接管与租约栅栏;不调用真实钉钉源、不连接共享或生产库;默认CI无库则明确跳过"
},
{
"package": "knowledge-governance/internal/qaopsorg",
"test": "TestOrganizationStoreRoundTripAndOptimisticConflict",
"reason": "仅在显式KG_QAOPS_ORG_TEST_ISOLATED=1且127.0.0.1上的一次性kg_qaops_org_test_库运行;写入治理侧快照与配置,禁止使用共享或生产库;本批已在隔离PG补验"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestSSHDialerTransportsRealTraffic",
"reason": "需要已批准 SSH 路径和只读版本接口;CI 不带生产 SSH 凭据,缺环境时显式跳过"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestSSHTunnelCloseReapsChild",
"reason": "需要已批准 SSH 目标来验证子进程回收;无路径的 CI 不应尝试建立生产连接"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestReadOnlyRepositoryAgainstFixtureDatabase",
"reason": "需显式 KG_DOBOTAI_TEST_ISOLATED=1 且 TEST_DATABASE_URL 通过隔离库护栏,CI 不建生产测试表;本批已在一次性测试库补验"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestBuildRefusesUnsafeAccountsAndTargets",
"reason": "只在显式一次性测试库创建危险权限夹具,不在生产库验证写入;本批已在隔离库补验"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestSourceRechecksEffectivePrivilegesBeforeEveryQuery",
"reason": "在一次性测试库模拟额外表写权限,测试前须通过隔离护栏;本批已实际补验"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestQARecordSearchPreservesSourceAndDoesNotMultiplyFeedback",
"reason": "完整查询/反馈/游标/空列验证需要显式一次性测试库;本批已在隔离库补验"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestProductionReadOnlyCombinedVerification",
"reason": "需独立批准的生产只读账号文件与 SSH 路径并显式 KG_DOBOTAI_PROD_VERIFY=1;CI 永不自动读取生产库"
},
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestProductionQAQueryReadOnlyVerification",
"reason": "真实源查询端到端验证仅在显式 KG_DOBOTAI_PROD_VERIFY=1、批准的凭据文件与 SSH 路径下执行;CI 不访问生产,测试不写源库"
},
{
"package": "knowledge-governance/internal/platform/postgres",
"reason": "整个包的集成用例都要 TEST_DATABASE_URL 指向可销毁测试库;本仓不连生产库,CI 无库 ⇒ 跳过(补验路径:起一次性 PG 后设 TEST_DATABASE_URL)"
+65
View File
@@ -0,0 +1,65 @@
import importlib.util
import logging
from pathlib import Path
import unittest
spec=importlib.util.spec_from_file_location('qa_ops_directory_sync',Path(__file__).with_name('qa_ops_directory_sync.py'))
assert spec is not None and spec.loader is not None
sync=importlib.util.module_from_spec(spec)
spec.loader.exec_module(sync)
class FakeClient:
class Config:
dry_run=False
def __init__(self):
self.config=self.Config()
def full_departments(client,logger,include,exclude):
return {10:{'id':10,'parent_id':1,'name':'示例业务'}},set(),set()
def full_users(client,logger,ids,enable_user_detail):
if enable_user_detail:
raise AssertionError('Must not inherit silent per-user detail failures or guessed primary department')
return {'fixture-user':{'user_id':'fixture-user','department_ids':{10},'raw_payload':{'unionid':'fixture-union'}}},set(),set()
def root_detail(client,logger,department_id):
return {'id':department_id,'parent_id':0,'name':'真实源端返回的示例根部门'},False,False
class DirectoryBridgeTests(unittest.TestCase):
def test_real_display_name_is_retained_without_guessing_or_requiring_it(self):
def users(*args,**kwargs):
return {'u':{'user_id':'u','name':'<示例员工>','department_ids':{10},'raw_payload':{}}},set(),set()
result=sync.collect_directory(FakeClient(),full_departments,users,root_detail,logging.getLogger('fixture'))
self.assertEqual(result['directory']['people'][0].get('name'),'<示例员工>')
result=sync.collect_directory(FakeClient(),full_departments,full_users,root_detail,logging.getLogger('fixture'))
self.assertNotIn('name',result['directory']['people'][0])
def test_fetches_the_real_missing_root_and_preserves_stable_identities(self):
result=sync.collect_directory(FakeClient(),full_departments,full_users,root_detail,logging.getLogger('fixture'))
self.assertTrue(result['complete'])
self.assertEqual(result['source'],'weekly_report_live')
self.assertEqual(result['directory']['departments'],[
{'id':'1','parent_id':'','name':'真实源端返回的示例根部门'},
{'id':'10','parent_id':'1','name':'示例业务'}])
self.assertEqual(result['directory']['people'],[{'id':'fixture-user','union_id':'fixture-union','department_ids':['10']}])
def test_inaccessible_root_is_not_replaced_with_a_fabricated_company(self):
def denied(*args):return None,True,False
with self.assertRaisesRegex(sync.DirectorySyncError,'directory_ancestor_unavailable'):
sync.collect_directory(FakeClient(),full_departments,full_users,denied,logging.getLogger('fixture'))
def test_partial_fetch_and_dry_run_do_not_become_complete_live_snapshots(self):
def partial(*args):return {},set(),{10}
with self.assertRaises(sync.DirectorySyncError):
sync.collect_directory(FakeClient(),partial,full_users,root_detail,logging.getLogger('fixture'))
client=FakeClient();client.config.dry_run=True
with self.assertRaises(sync.DirectorySyncError):
sync.collect_directory(client,full_departments,full_users,root_detail,logging.getLogger('fixture'))
def test_union_id_does_not_fall_back_to_open_id(self):
def users(*args,**kwargs):return {'u':{'user_id':'u','department_ids':{10},'union_id':'not-a-union','raw_payload':{'openId':'not-a-union'}}},set(),set()
result=sync.collect_directory(FakeClient(),full_departments,users,root_detail,logging.getLogger('fixture'))
self.assertEqual(result['directory']['people'][0]['union_id'],'')
def test_wrong_root_identity_and_cycles_are_rejected(self):
def wrong(*args):return {'id':999,'parent_id':0,'name':'wrong'},False,False
with self.assertRaises(sync.DirectorySyncError):
sync.collect_directory(FakeClient(),full_departments,full_users,wrong,logging.getLogger('fixture'))
def cyclic(*args):return {1:{'id':1,'parent_id':10,'name':'a'},10:{'id':10,'parent_id':1,'name':'b'}},set(),set()
with self.assertRaises(sync.DirectorySyncError):
sync.collect_directory(FakeClient(),cyclic,full_users,root_detail,logging.getLogger('fixture'))
if __name__=='__main__':unittest.main()

Some files were not shown because too many files have changed in this diff Show More