merge: candidate error-code-observability-2026-09-28 into dev

This commit is contained in:
weichen
2026-09-28 02:37:48 +00:00
79 changed files with 2442 additions and 176 deletions
+1
View File
@@ -47,6 +47,7 @@
| 整改方案 | [remediation/INDEX.md](remediation/INDEX.md) |
| 历史归档 | [archive/README.md](archive/README.md) |
| 架构设计 | [system-design/README.md](system-design/README.md) |
| 用错误码定位失败 | [错误码目录](system-design/2026-09-28-error-code-catalog.md) |
| 测试报告 | [test-reports/](test-reports/) |
| 历史功能设计 | [archive/design/superpowers/](archive/design/superpowers/) |
| Herdr 角色、审批与备援 | [ops/2026-09-13-herdr-agent-roster.md](ops/2026-09-13-herdr-agent-roster.md) |
+1
View File
@@ -18,6 +18,7 @@
| owner | task | branch | worktree | mutex | status |
|---|---|---|---|---|---|
| Cursor | error-code-observability-2026-09-28 | feature/error-code-observability-2026-09-28 | .worktrees/error-code-observability-2026-09-28 | error-code-observability | in_progress |
| Cursor | attachment-only-turn-2026-09-28 | fix/attachment-only-turn-2026-09-28 | .worktrees/attachment-only-turn-2026-09-28 | attachment-only-turn | done |
| Cursor | activation-preview-precheck-2026-09-28 | fix/activation-preview-400-precheck-2026-09-28 | .worktrees/activation-preview-400-precheck-2026-09-28 | web-activation-preview | done |
| Cursor | squad-commit-authority-2026-09-28 | fix/squad-commit-authority-2026-09-28 | .worktrees/squad-commit-authority-2026-09-28 | bizdev+docflow | in_progress |
@@ -0,0 +1,75 @@
---
title: "统一错误码、结构化日志与错误审计"
task_id: "error-code-observability-2026-09-28"
status: in_progress
created: 2026-09-28
updated: 2026-09-28
owner: Cursor
task_type: implementation
task_tier: multi_stage
policy_revision: "agent-governance-2026-09-21-r1 @ 3e534bbb39d0d957e0b720a4ca06f22dd15959e6"
branch: feature/error-code-observability-2026-09-28
worktree: .worktrees/error-code-observability-2026-09-28
base_dev_sha: "3e534bbb39d0d957e0b720a4ca06f22dd15959e6"
write_scope: ["internal", "deploy/postgres/goose", "web", ".doc"]
allowed_environments: []
head_sha: ""
mutex: error-code-observability
review_state: accepted
integration_state: pending
deployment: not_required
cleanup: pending
cleanup_owner: "独立集成/维护执行者"
cleanup_trigger: "dev 合入且验收证据落盘后复查 worktree"
remote_sync: not_requested
---
# 统一错误码、结构化日志与错误审计
## 用户目标与授权
- 用户目标:为系统加入详细错误码、日志和审计,使失败能凭 `code + request_id` 定位,不必靠翻代码。审计覆盖 HTTP 与异步错误;错误码覆盖全部模块。授权来自本对话对计划「统一错误码 + 结构化日志 + 错误审计体系」的实施指令。
- 交付终点:注册表、HTTP 错误日志、`error_events`、管理台查询、流式分类读取类型化错误码、`err.Error()` 透传收敛、错误码目录,以及验证报告后合入 local dev。
- 明确禁止项:不 push,不晋升 test/main,不改 FEATURE-MATRIX 状态值,不操作共享运行环境。
- 例外:无。
## 范围与非目标
- 允许写入:`internal/errcode`、`internal/errorevents`、`internal/httpx`、`internal/server`、`internal/stream`、`internal/app`、`internal/events`、`internal/taskrun`、`internal/ctxmgr`、`deploy/postgres/goose/00078_*`、`web` 管理台与失败气泡、`.doc` 目录与任务记录。
- 非目标:不引入 OpenTelemetry;不改变错误信封的顶层字段名;不把审计写失败变成请求失败。
## 相关问题与经验
- issue_ids:无单独问题单。开工检索 `错误码`、`writeSafeError`、`error_events`:问题台账与 lessons 索引没有可复用的统一错误码条目。采纳的检查是模块边界 allowlist、信封脱敏 `LooksInternal`、审计写失败不阻断请求。
- 不新增 lesson:机制已写进错误码目录;待合入后再看是否需要经验条目。
## 步骤与验收
1. [x] `internal/errcode` 注册表与唯一性/脱敏测试。
2. [x] `httpx` 错误日志、`X-Request-ID` 始终下发、访问日志 `err_code`。
3. [x] 迁移 `00078`、`errorevents` 写端口、`app` 注入、异步失败上报。
4. [x] 管理台 `GET /api/v1/admin/system/error-events` 与错误审计界面。
5. [x] `ClassifyError` 优先读取 `ErrorCode()`。
6. [x] 带 `.Error()` 的 `writeSafeError` 改为 `writeDomainError`;字面量仍走 `FromLegacy`。
7. [x] 错误码目录与文档地图。
8. [x] `arch-check`、`go vet`、`go test -short`、前端测试与构建,测试报告。
9. [ ] `validate` 后 `queue enqueue` 合入 dev。
## 验证与证据
| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 |
|---|---|---|---|---|
| 工作树未提交源码,基线 3e534bbb | 见测试报告第五节 | 工程门禁与前端测试通过 | PASS;审计 PG 往返 SKIP | [测试报告](../../test-reports/2026-09-28-error-code-audit-test-report.md) |
产品 PI 全链路不在本次范围:改动是错误信封、日志和审计查询,不改变 Runner 协议。未运行的 PI 链路不记为 PASS。
## 收口与交接
- 剩余工作:验证、提交、合入。
- 本地提交 / dev 集成 / 部署制品 / 远端状态:提交前均为 pending;deployment=not_required;remote_sync=not_requested。
- cleanup:合入后由独立维护者复查,本任务不删除 worktree。
## 相关链接
- [错误码目录](../../system-design/2026-09-28-error-code-catalog.md)
- [测试报告](../../test-reports/2026-09-28-error-code-audit-test-report.md)
@@ -0,0 +1,228 @@
# 错误码目录
日期:2026-09-28。稳定码注册在 `internal/errcode`。HTTP 响应信封仍是 `code`、`message`、`request_id`、`error`、`details`。`details.guide` 是本页对应码的排查短句。
## 怎么定位一次失败
1. 从响应、聊天失败气泡或访问日志取出 `code` 与 `request_id`。
2. 在服务日志里检索 `request_id`(错误日志字段还有 `code`、`status`、`method`、`path`)。5xx 记为 Error,4xx 记为 Warn。日志不写原始 SQL、密钥或堆栈。
3. 管理台「错误审计」调用 `GET /api/v1/admin/system/error-events`(权限 `system:audit:read`),可按 `code`、`request_id`、`class`、时间范围过滤。
4. 异步失败(任务收尾、事件死信/隔离、上下文后台扫描、提醒扫描)写入同一张表,`source=async`。没有 HTTP 请求时,`request_id` 可能是任务或事件标识。
## 常见原因
| 类别 | 常见原因 | 下一步 |
|---|---|---|
| transient | 超时、暂时不可用、后台扫描失败 | 稍后重试;用 request_id 看是否连续出现 |
| resumable | 文档忙、租约占用、阶段未完成 | 等待当前编辑或阶段结束后继续 |
| fatal | 权限、校验、不存在、账本不一致 | 按码的含义修正输入或权限,不要盲目重试 |
| cancelled | 任务被取消 | 确认是否为用户取消 |
## 负责模块与相关表
- 表 `error_events` 的唯一写者是 `internal/errorevents`(迁移 `deploy/postgres/goose/00078_2026-09-28-error-events-audit.sql`)。按环境数据库隔离,表内没有环境列。
- 审计写入超时或失败只降级为日志 `error_event_audit_degraded`,不改变 HTTP 响应。
- 领域哨兵仍留在各领域包。`internal/server/domain_http.go` 在启动时 `errcode.Bind`。`stream.ClassifyError` 优先读错误上的 `ErrorCode()`,字符串启发式只作为旧路径兜底。
## 码表
下表每行一条注册码:含义、排查步骤、负责模块。HTTP 列为建议状态;处理器已显式给出的状态优先。
| 错误码 | HTTP | 类别 | 模块 | 可重试 | 含义 | 排查 |
|---|---:|---|---|---|---|---|
| `APPROVER_CANNOT_EDIT` | 403 | fatal | docgov | 否 | 批准人不能修改文档内容 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 APPROVER_CANNOT_EDIT。负责模块:docgov。 |
| `ASSET_KEY_AND_FILE_REQUIRED` | 400 | fatal | server | 否 | asset_key and file required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 ASSET_KEY_AND_FILE_REQUIRED。负责模块:server。 |
| `ASSET_NOT_FOUND` | 404 | fatal | server | 否 | asset not found | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 ASSET_NOT_FOUND。负责模块:server。 |
| `AUTHOR_ONLY_EDIT` | 403 | fatal | docgov | 否 | 只有编制人可以修改文档内容 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 AUTHOR_ONLY_EDIT。负责模块:docgov。 |
| `AUTH_BAD_CREDENTIALS` | 401 | fatal | auth | 否 | 用户名或密码错误 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 AUTH_BAD_CREDENTIALS。负责模块:auth。 |
| `AUTH_FORBIDDEN` | 403 | fatal | auth | 否 | 无权访问 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 AUTH_FORBIDDEN。负责模块:auth。 |
| `AUTH_LOGIN_REQUIRED` | 401 | fatal | auth | 否 | 请先登录 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 AUTH_LOGIN_REQUIRED。负责模块:auth。 |
| `AUTH_SESSION_EXPIRED` | 401 | fatal | auth | 否 | 未登录或登录已过期 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 AUTH_SESSION_EXPIRED。负责模块:auth。 |
| `AUTH_UNAUTHENTICATED` | 401 | fatal | auth | 否 | 未登录或登录已过期 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 AUTH_UNAUTHENTICATED。负责模块:auth。 |
| `AUTH_USERNAME_EXISTS` | 409 | fatal | auth | 否 | 用户名已存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 AUTH_USERNAME_EXISTS。负责模块:auth。 |
| `BATCH_NOT_FOUND` | 404 | fatal | server | 否 | batch not found | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 BATCH_NOT_FOUND。负责模块:server。 |
| `CANNOT_STORE_EMPTY_FILE` | 400 | fatal | server | 否 | cannot store empty file | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 CANNOT_STORE_EMPTY_FILE。负责模块:server。 |
| `CHANGE_PROPOSAL_PENDING` | 409 | resumable | docgov | 是 | 文档仍有待编制人确认的变更提案 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 CHANGE_PROPOSAL_PENDING。负责模块:docgov。 |
| `CHAT_MESSAGES_IMPORT_ENDPOINT_IS_ONLY_AVAILABLE_IN_DEV_D` | 400 | fatal | server | 否 | chat messages import endpoint is only available in dev/demo environment | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 CHAT_MESSAGES_IMPORT_ENDPOINT_IS_ONLY_AVAILABLE_IN_DEV_D。负责模块:server。 |
| `CHAT_TIMEOUT` | 504 | transient | stream | 是 | 生成回复超时,请稍后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 CHAT_TIMEOUT。负责模块:stream。 |
| `CHAT_TURN_NOT_FOUND` | 404 | fatal | ctxmgr | 否 | 对话回合不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 CHAT_TURN_NOT_FOUND。负责模块:ctxmgr。 |
| `CLIENT_TURN_ID_AND_SESSION_ID_REQUIRED` | 400 | fatal | server | 否 | client_turn_id and session_id required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 CLIENT_TURN_ID_AND_SESSION_ID_REQUIRED。负责模块:server。 |
| `CTXMGR_BACKGROUND` | 500 | transient | ctxmgr | 是 | 上下文后台任务失败 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 CTXMGR_BACKGROUND。负责模块:ctxmgr。 |
| `DECISION_NONCE_AND_DECISION_DIGEST_REQUIRED` | 400 | fatal | server | 否 | decision_nonce and decision_digest required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DECISION_NONCE_AND_DECISION_DIGEST_REQUIRED。负责模块:server。 |
| `DOCUMENT_CONTENT_ERROR` | 409 | fatal | docgov | 否 | 文档内容读取失败 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOCUMENT_CONTENT_ERROR。负责模块:docgov。 |
| `DOCUMENT_CONTENT_INTEGRITY_MISMATCH` | 409 | fatal | docgov | 否 | 文档内容校验不一致 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOCUMENT_CONTENT_INTEGRITY_MISMATCH。负责模块:docgov。 |
| `DOCUMENT_CONTENT_PROJECTION_DRIFT` | 409 | fatal | docgov | 否 | 文档内容投影与账本不一致 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOCUMENT_CONTENT_PROJECTION_DRIFT。负责模块:docgov。 |
| `DOCUMENT_CONTENT_PROJECTION_EMPTY` | 409 | fatal | docgov | 否 | 文档内容投影为空 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOCUMENT_CONTENT_PROJECTION_EMPTY。负责模块:docgov。 |
| `DOCUMENT_CONTENT_PROJECTION_MISSING` | 409 | fatal | docgov | 否 | 文档内容投影缺失 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOCUMENT_CONTENT_PROJECTION_MISSING。负责模块:docgov。 |
| `DOCUMENT_LOCKED` | 409 | resumable | docgov | 是 | 文档已锁定 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOCUMENT_LOCKED。负责模块:docgov。 |
| `DOCUMENT_NOT_FOUND` | 404 | fatal | docgov | 否 | 文档不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOCUMENT_NOT_FOUND。负责模块:docgov。 |
| `DOC_BUSY` | 409 | fatal | docgov | 否 | 该文档正在编辑中,请等待现有任务完成 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOC_BUSY。负责模块:docgov。 |
| `DOC_ID_CANNOT_BE_EMPTY` | 400 | fatal | server | 否 | doc_id cannot be empty | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOC_ID_CANNOT_BE_EMPTY。负责模块:server。 |
| `DOC_ID_REQUIRED` | 400 | fatal | server | 否 | doc_id required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOC_ID_REQUIRED。负责模块:server。 |
| `DOC_LEDGER_ERROR` | 500 | fatal | docgov | 否 | 文档职责账本写入失败,请重新开始会话 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOC_LEDGER_ERROR。负责模块:docgov。 |
| `DOC_TASK_BUSY` | 409 | resumable | projectinstance | 是 | 文档任务正在执行,暂不能改派 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 DOC_TASK_BUSY。负责模块:projectinstance。 |
| `EVENTS_AUTH_FAILURE` | 403 | fatal | events | 否 | 事件未通过授权 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 EVENTS_AUTH_FAILURE。负责模块:events。 |
| `EVENTS_DEAD` | 500 | fatal | events | 否 | 事件投递已进入死信 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 EVENTS_DEAD。负责模块:events。 |
| `EVENTS_INTEGRITY` | 500 | fatal | events | 否 | 事件完整性校验失败 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 EVENTS_INTEGRITY。负责模块:events。 |
| `EVENTS_QUARANTINE` | 500 | fatal | events | 否 | 事件已隔离,不会自动重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 EVENTS_QUARANTINE。负责模块:events。 |
| `EVENTS_STALE_FENCING` | 409 | resumable | events | 是 | 事件租约令牌已过期 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 EVENTS_STALE_FENCING。负责模块:events。 |
| `FILE_REQUIRED` | 400 | fatal | server | 否 | file required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FILE_REQUIRED。负责模块:server。 |
| `FILE_TYPE_NOT_SUPPORTED_BY_PARSER` | 400 | fatal | server | 否 | file type not supported by parser | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FILE_TYPE_NOT_SUPPORTED_BY_PARSER。负责模块:server。 |
| `FORCE_EDIT_NOT_ALLOWED` | 409 | fatal | docgov | 否 | 当前不允许强制直接编辑 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FORCE_EDIT_NOT_ALLOWED。负责模块:docgov。 |
| `FREEDOC_CONFLICT` | 409 | resumable | freedoc | 是 | 自由文档版本冲突 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FREEDOC_CONFLICT。负责模块:freedoc。 |
| `FREEDOC_FORBIDDEN` | 403 | fatal | freedoc | 否 | 无权访问该自由文档 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FREEDOC_FORBIDDEN。负责模块:freedoc。 |
| `FREEDOC_INVALID_PARAM` | 400 | fatal | freedoc | 否 | 自由文档参数无效 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FREEDOC_INVALID_PARAM。负责模块:freedoc。 |
| `FREEDOC_NOT_EXPORTABLE` | 400 | fatal | freedoc | 否 | 该消息不能导出为文档 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FREEDOC_NOT_EXPORTABLE。负责模块:freedoc。 |
| `FREEDOC_NOT_FOUND` | 404 | fatal | freedoc | 否 | 自由文档不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 FREEDOC_NOT_FOUND。负责模块:freedoc。 |
| `IAM` | 400 | fatal | server | 否 | 工作流定义服务需要数据库 IAM | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 IAM。负责模块:server。 |
| `ID` | 400 | fatal | server | 否 | 无效的 id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 ID。负责模块:server。 |
| `IDEMPOTENCY_CONFLICT` | 409 | resumable | server | 是 | 重复提交 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 IDEMPOTENCY_CONFLICT。负责模块:server。 |
| `IDEMPOTENCY_KEY_REUSED` | 409 | fatal | ctxmgr | 否 | 重复提交的内容不一致 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 IDEMPOTENCY_KEY_REUSED。负责模块:ctxmgr。 |
| `ID_2` | 400 | fatal | server | 否 | 无效的用户ID | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 ID_2。负责模块:server。 |
| `ID_3` | 400 | fatal | server | 否 | 无效的角色 ID | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 ID_3。负责模块:server。 |
| `INSTANCE_ACTIVATION_INVALID` | 400 | fatal | projectinstance | 否 | 激活令牌无效或已过期 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_ACTIVATION_INVALID。负责模块:projectinstance。 |
| `INSTANCE_ALREADY_ACTIVE` | 409 | resumable | projectinstance | 是 | 项目已有运行中的工作流实例 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_ALREADY_ACTIVE。负责模块:projectinstance。 |
| `INSTANCE_AUTHOR_ONLY_CHANGED` | 409 | resumable | projectinstance | 是 | 兼任总授权已变更,请重新预览并确认激活 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_AUTHOR_ONLY_CHANGED。负责模块:projectinstance。 |
| `INSTANCE_AUTHOR_ONLY_DISABLED` | 403 | fatal | projectinstance | 否 | 系统未授权编制人兼任审核批准 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_AUTHOR_ONLY_DISABLED。负责模块:projectinstance。 |
| `INSTANCE_AUTHOR_ONLY_UNAVAILABLE` | 503 | transient | projectinstance | 是 | 兼任总授权存储不可用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_AUTHOR_ONLY_UNAVAILABLE。负责模块:projectinstance。 |
| `INSTANCE_CONFLICT` | 409 | resumable | projectinstance | 是 | 项目版本冲突,请刷新后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_CONFLICT。负责模块:projectinstance。 |
| `INSTANCE_DOC_TASK_NOT_FOUND` | 404 | fatal | projectinstance | 否 | 文档任务不存在或已禁用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_DOC_TASK_NOT_FOUND。负责模块:projectinstance。 |
| `INSTANCE_DUTY_FORBIDDEN` | 403 | fatal | projectinstance | 否 | 无权改派文档职责 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_DUTY_FORBIDDEN。负责模块:projectinstance。 |
| `INSTANCE_FORBIDDEN_NOT_CREATOR` | 403 | fatal | projectinstance | 否 | 仅项目创建人可配置工作流 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_FORBIDDEN_NOT_CREATOR。负责模块:projectinstance。 |
| `INSTANCE_INVALID_STAGE` | 400 | fatal | projectinstance | 否 | 无效的阶段 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_INVALID_STAGE。负责模块:projectinstance。 |
| `INSTANCE_LEGACY_SWITCH_BLOCKED` | 409 | fatal | projectinstance | 否 | 新项目请通过项目设置完成工作流激活 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_LEGACY_SWITCH_BLOCKED。负责模块:projectinstance。 |
| `INSTANCE_NOT_AVAILABLE` | 503 | transient | projectinstance | 是 | 项目实例服务不可用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_NOT_AVAILABLE。负责模块:projectinstance。 |
| `INSTANCE_NOT_FOUND` | 404 | fatal | projectinstance | 否 | 工作流实例不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_NOT_FOUND。负责模块:projectinstance。 |
| `INSTANCE_PLAN_CONFLICT` | 409 | resumable | projectinstance | 是 | 规划版本冲突,请刷新后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_PLAN_CONFLICT。负责模块:projectinstance。 |
| `INSTANCE_PLAN_FROZEN` | 409 | fatal | projectinstance | 否 | 工作流已激活,规划已冻结 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_PLAN_FROZEN。负责模块:projectinstance。 |
| `INSTANCE_PLAN_RECONFIRM` | 409 | resumable | projectinstance | 是 | 规划已变更,请重新预览并确认 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_PLAN_RECONFIRM。负责模块:projectinstance。 |
| `INSTANCE_RECHECK_REQUIRED` | 409 | fatal | projectinstance | 否 | 该阶段有待复核标记,推进前须确认已知悉 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_RECHECK_REQUIRED。负责模块:projectinstance。 |
| `INSTANCE_REVISION_CONFLICT` | 409 | resumable | projectinstance | 是 | 实例版本冲突,请刷新后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_REVISION_CONFLICT。负责模块:projectinstance。 |
| `INSTANCE_STAGE_GATE` | 400 | fatal | projectinstance | 否 | 阶段门禁未满足,必需文档尚未全部批准 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_STAGE_GATE。负责模块:projectinstance。 |
| `INSTANCE_STAGE_NOT_ACTIVE` | 409 | fatal | projectinstance | 否 | 目标阶段不是当前活动阶段 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_STAGE_NOT_ACTIVE。负责模块:projectinstance。 |
| `INSTANCE_STAGE_NOT_COMPLETED` | 400 | fatal | projectinstance | 否 | 只能再打开已完成的阶段 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_STAGE_NOT_COMPLETED。负责模块:projectinstance。 |
| `INSTANCE_SWITCH_FROZEN` | 409 | fatal | projectinstance | 否 | 运行中不能切换工作流,只能调整职责、审批和文档启用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_SWITCH_FROZEN。负责模块:projectinstance。 |
| `INSTANCE_TERMINATION_DENIED` | 409 | fatal | projectinstance | 否 | 当前状态不允许终止 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_TERMINATION_DENIED。负责模块:projectinstance。 |
| `INSTANCE_VALIDATION_FAILED` | 400 | fatal | projectinstance | 否 | 工作流配置校验未通过 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INSTANCE_VALIDATION_FAILED。负责模块:projectinstance。 |
| `INTERNAL` | 500 | fatal | server | 否 | 请求无法完成 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INTERNAL。负责模块:server。 |
| `INTERNAL_ERROR` | 500 | fatal | server | 否 | 系统内部错误,请重新开始会话 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INTERNAL_ERROR。负责模块:server。 |
| `INVALID_ACTION_MUST_BE_APPROVE_OR_REJECT` | 400 | fatal | server | 否 | invalid action: must be approve or reject | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_ACTION_MUST_BE_APPROVE_OR_REJECT。负责模块:server。 |
| `INVALID_ASSET_ID` | 400 | fatal | server | 否 | invalid asset id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_ASSET_ID。负责模块:server。 |
| `INVALID_ASSET_KEY` | 400 | fatal | server | 否 | invalid asset key | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_ASSET_KEY。负责模块:server。 |
| `INVALID_BATCH_ID` | 400 | fatal | server | 否 | invalid batch id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_BATCH_ID。负责模块:server。 |
| `INVALID_COMMENT_ID` | 400 | fatal | server | 否 | invalid comment id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_COMMENT_ID。负责模块:server。 |
| `INVALID_EDGE_ID` | 400 | fatal | server | 否 | invalid edge id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_EDGE_ID。负责模块:server。 |
| `INVALID_INSTANCE_ID` | 400 | fatal | server | 否 | invalid instance id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_INSTANCE_ID。负责模块:server。 |
| `INVALID_MARK_ID` | 400 | fatal | server | 否 | invalid mark id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_MARK_ID。负责模块:server。 |
| `INVALID_MULTIPART` | 400 | fatal | server | 否 | invalid multipart | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_MULTIPART。负责模块:server。 |
| `INVALID_PROJECT_ID` | 400 | fatal | server | 否 | invalid project id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_PROJECT_ID。负责模块:server。 |
| `INVALID_REPO_PATH` | 400 | fatal | server | 否 | invalid repo_path | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_REPO_PATH。负责模块:server。 |
| `INVALID_REQUEST_ID` | 400 | fatal | server | 否 | invalid request id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_REQUEST_ID。负责模块:server。 |
| `INVALID_STATE_TRANSITION` | 409 | fatal | docgov | 否 | 文档状态不能这样流转 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_STATE_TRANSITION。负责模块:docgov。 |
| `INVALID_TASK_KEY` | 400 | fatal | server | 否 | invalid task key | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_TASK_KEY。负责模块:server。 |
| `INVALID_VERSION` | 409 | resumable | server | 是 | invalid version | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_VERSION。负责模块:server。 |
| `INVALID_WORKFLOW_ID` | 400 | fatal | server | 否 | invalid workflow id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_WORKFLOW_ID。负责模块:server。 |
| `INVALID_WORKSPACE_PATH` | 400 | fatal | server | 否 | invalid workspace path | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 INVALID_WORKSPACE_PATH。负责模块:server。 |
| `KIND_MUST_BE_CONFIRM_OR_CANCEL` | 400 | fatal | server | 否 | kind must be confirm or cancel | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 KIND_MUST_BE_CONFIRM_OR_CANCEL。负责模块:server。 |
| `LEASE_EXPIRED` | 409 | resumable | docgov | 是 | 文档编辑租约已过期 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 LEASE_EXPIRED。负责模块:docgov。 |
| `LEASE_NOT_HELD` | 409 | resumable | docgov | 是 | 当前没有文档编辑租约 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 LEASE_NOT_HELD。负责模块:docgov。 |
| `LEASE_STALE` | 409 | resumable | docgov | 是 | 文档编辑租约已失效 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 LEASE_STALE。负责模块:docgov。 |
| `LIMIT` | 400 | fatal | server | 否 | 无效的 limit | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 LIMIT。负责模块:server。 |
| `LIST_EXECUTIONS_FAILED` | 400 | fatal | server | 否 | list executions failed | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 LIST_EXECUTIONS_FAILED。负责模块:server。 |
| `MAINTENANCE_BAD_STATUS` | 409 | fatal | maintenance | 否 | 当前状态不可取消 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MAINTENANCE_BAD_STATUS。负责模块:maintenance。 |
| `MAINTENANCE_CONFLICT_OPEN` | 409 | resumable | maintenance | 是 | 已有未结束的维护窗口 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MAINTENANCE_CONFLICT_OPEN。负责模块:maintenance。 |
| `MAINTENANCE_INVALID_WINDOW` | 400 | fatal | maintenance | 否 | 维护窗口参数无效 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MAINTENANCE_INVALID_WINDOW。负责模块:maintenance。 |
| `MAINTENANCE_NOT_FOUND` | 404 | fatal | maintenance | 否 | 维护窗口不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MAINTENANCE_NOT_FOUND。负责模块:maintenance。 |
| `MESSAGE_CANNOT_BE_EMPTY` | 400 | fatal | server | 否 | message cannot be empty | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MESSAGE_CANNOT_BE_EMPTY。负责模块:server。 |
| `MESSAGE_MUST_BE_SENT_VIA_POST_JSON_BODY_NOT_GET_QUERY_T2` | 400 | fatal | server | 否 | message must be sent via POST JSON body, not GET query (T20-B11) | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MESSAGE_MUST_BE_SENT_VIA_POST_JSON_BODY_NOT_GET_QUERY_T2。负责模块:server。 |
| `MISSING_DATE_DAY_KEY_OR_ANCHOR_ID_QUERY_PARAM` | 400 | fatal | server | 否 | missing date/day_key or anchor_id query param | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_DATE_DAY_KEY_OR_ANCHOR_ID_QUERY_PARAM。负责模块:server。 |
| `MISSING_DOC_ID_QUERY_PARAM` | 400 | fatal | server | 否 | missing doc_id query param | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_DOC_ID_QUERY_PARAM。负责模块:server。 |
| `MISSING_OR_EMPTY_QUERY_PARAM_Q` | 400 | fatal | server | 否 | missing or empty query param 'q' | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_OR_EMPTY_QUERY_PARAM_Q。负责模块:server。 |
| `MISSING_OR_INVALID_PROJECT_ID_QUERY_PARAM` | 400 | fatal | server | 否 | missing or invalid project_id query param | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_OR_INVALID_PROJECT_ID_QUERY_PARAM。负责模块:server。 |
| `MISSING_QUERY_PARAM` | 400 | fatal | server | 否 | missing query param | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_QUERY_PARAM。负责模块:server。 |
| `MISSING_REQUIRED_FIELDS_SESSION_ID_OR_DAY_KEY` | 400 | fatal | server | 否 | missing required fields 'session_id' or 'day_key' | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_REQUIRED_FIELDS_SESSION_ID_OR_DAY_KEY。负责模块:server。 |
| `MISSING_REQUIRED_QUERY_PARAM_SESSION_ID` | 400 | fatal | server | 否 | missing required query param 'session_id' | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_REQUIRED_QUERY_PARAM_SESSION_ID。负责模块:server。 |
| `MISSING_SESSION_ID` | 400 | fatal | server | 否 | missing session_id | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_SESSION_ID。负责模块:server。 |
| `MISSING_SESSION_ID_QUERY_PARAM` | 400 | fatal | server | 否 | missing session_id query param | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MISSING_SESSION_ID_QUERY_PARAM。负责模块:server。 |
| `MSG_` | 400 | fatal | server | 否 | 仅原批准人或(批准人缺位时)项目创建人可批准再打开 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG_。负责模块:server。 |
| `MSG__2` | 400 | fatal | server | 否 | 只有该阶段文档职责人或创建人可以申请再打开 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__2。负责模块:server。 |
| `MSG__3` | 400 | fatal | server | 否 | 姓名不能为空 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__3。负责模块:server。 |
| `MSG__4` | 400 | fatal | server | 否 | 指定的角色不存在或已停用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__4。负责模块:server。 |
| `MSG__5` | 404 | fatal | server | 否 | 未找到该项目的打开标记 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__5。负责模块:server。 |
| `MSG__6` | 400 | fatal | server | 否 | 申请不属于该项目 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__6。负责模块:server。 |
| `MSG__7` | 400 | fatal | server | 否 | 看不见下游文档,不能裁决 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__7。负责模块:server。 |
| `MSG__8` | 400 | fatal | server | 否 | 至少提供一个可更新字段 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__8。负责模块:server。 |
| `MSG__9` | 400 | fatal | server | 否 | 追踪索引不可用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 MSG__9。负责模块:server。 |
| `NETWORK_ERROR` | 502 | transient | stream | 是 | 网络连接异常,请稍后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 NETWORK_ERROR。负责模块:stream。 |
| `PARSE_IS_IN_PROGRESS_OR_ALREADY_COMPLETE` | 409 | resumable | server | 是 | parse is in progress or already complete | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PARSE_IS_IN_PROGRESS_OR_ALREADY_COMPLETE。负责模块:server。 |
| `PROJECT_NAME_DUPLICATE` | 409 | fatal | projectworkflow | 否 | 项目名称已存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROJECT_NAME_DUPLICATE。负责模块:projectworkflow。 |
| `PROJECT_NOT_FOUND` | 404 | fatal | projectworkflow | 否 | 项目不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROJECT_NOT_FOUND。负责模块:projectworkflow。 |
| `PROJECT_ROLE_REQUIRED_WHEN_MEMBER_DOES_NOT_EXIST` | 400 | fatal | server | 否 | project_role required when member does not exist | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROJECT_ROLE_REQUIRED_WHEN_MEMBER_DOES_NOT_EXIST。负责模块:server。 |
| `PROJECT_ROLE_REQUIRED_WITH_DOCUMENT_DUTY` | 400 | fatal | server | 否 | project_role required with document_duty | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROJECT_ROLE_REQUIRED_WITH_DOCUMENT_DUTY。负责模块:server。 |
| `PROPOSAL_ALREADY_DECIDED` | 409 | fatal | docgov | 否 | 变更提案已裁决 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROPOSAL_ALREADY_DECIDED。负责模块:docgov。 |
| `PROPOSAL_ID_CANNOT_BE_EMPTY` | 400 | fatal | server | 否 | proposal_id cannot be empty | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROPOSAL_ID_CANNOT_BE_EMPTY。负责模块:server。 |
| `PROPOSAL_NOT_FOUND` | 404 | fatal | docgov | 否 | 变更提案不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROPOSAL_NOT_FOUND。负责模块:docgov。 |
| `PROPOSED_CHANGES_CANNOT_BE_EMPTY` | 400 | fatal | server | 否 | proposed_changes cannot be empty | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 PROPOSED_CHANGES_CANNOT_BE_EMPTY。负责模块:server。 |
| `RATE_LIMITED` | 429 | transient | server | 是 | 请求过于频繁,请稍后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 RATE_LIMITED。负责模块:server。 |
| `READ_FILE_FAILED` | 400 | fatal | server | 否 | read file failed | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 READ_FILE_FAILED。负责模块:server。 |
| `REJECT_REASON_REQUIRED` | 400 | fatal | docgov | 否 | 驳回必须填写原因 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REJECT_REASON_REQUIRED。负责模块:docgov。 |
| `REMINDER_SCAN_FAILED` | 500 | transient | notification | 是 | 阶段提醒扫描失败 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REMINDER_SCAN_FAILED。负责模块:notification。 |
| `REPO_PATH_REQUIRED` | 400 | fatal | server | 否 | repo_path required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REPO_PATH_REQUIRED。负责模块:server。 |
| `REQUEST_BODY_INVALID` | 400 | fatal | server | 否 | 无效的请求体 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REQUEST_BODY_INVALID。负责模块:server。 |
| `REQUEST_INVALID` | 400 | fatal | server | 否 | 请求参数无效 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REQUEST_INVALID。负责模块:server。 |
| `REQUEST_JSON_INVALID` | 400 | fatal | server | 否 | 无效的 JSON 请求体 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REQUEST_JSON_INVALID。负责模块:server。 |
| `REQUEST_METHOD_NOT_ALLOWED` | 405 | fatal | server | 否 | 不支持的请求方法 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REQUEST_METHOD_NOT_ALLOWED。负责模块:server。 |
| `RESOURCE_CONFLICT` | 409 | resumable | server | 是 | 资源版本冲突 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 RESOURCE_CONFLICT。负责模块:server。 |
| `RESOURCE_NOT_FOUND` | 404 | fatal | server | 否 | 资源不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 RESOURCE_NOT_FOUND。负责模块:server。 |
| `REVIEWER_CANNOT_EDIT` | 403 | fatal | docgov | 否 | 审核人不能修改文档内容 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 REVIEWER_CANNOT_EDIT。负责模块:docgov。 |
| `RUNNER_RESULT_REJECTED` | 502 | fatal | stream | 否 | 助手执行结果校验失败,请重新开始任务 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 RUNNER_RESULT_REJECTED。负责模块:stream。 |
| `RUN_ID_REQUIRED` | 400 | fatal | server | 否 | run_id required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 RUN_ID_REQUIRED。负责模块:server。 |
| `RUN_NOT_FOUND` | 404 | fatal | server | 否 | run not found | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 RUN_NOT_FOUND。负责模块:server。 |
| `SAVE_BINDING_FAILED` | 400 | fatal | server | 否 | save binding failed | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SAVE_BINDING_FAILED。负责模块:server。 |
| `SERVICE_UNAVAILABLE` | 503 | transient | server | 是 | 服务暂不可用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SERVICE_UNAVAILABLE。负责模块:server。 |
| `SESSION_OR_PROJECT_CONTEXT_NOT_FOUND` | 404 | fatal | server | 否 | session or project context not found | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SESSION_OR_PROJECT_CONTEXT_NOT_FOUND。负责模块:server。 |
| `SESSION_OWNER_MISMATCH` | 403 | fatal | ctxmgr | 否 | 无权访问该会话 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SESSION_OWNER_MISMATCH。负责模块:ctxmgr。 |
| `SETTING_INVALID_VALUE` | 400 | fatal | sysadmin | 否 | 设置值类型不匹配 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SETTING_INVALID_VALUE。负责模块:sysadmin。 |
| `SETTING_NOT_FOUND` | 404 | fatal | sysadmin | 否 | 设置不存在 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SETTING_NOT_FOUND。负责模块:sysadmin。 |
| `SETTING_READ_ONLY` | 403 | fatal | sysadmin | 否 | 该设置为只读 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SETTING_READ_ONLY。负责模块:sysadmin。 |
| `SETTING_REVISION_CONFLICT` | 409 | resumable | sysadmin | 是 | 设置版本冲突,请刷新后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SETTING_REVISION_CONFLICT。负责模块:sysadmin。 |
| `SETTING_REVISION_REQUIRED` | 400 | fatal | sysadmin | 否 | 该设置需要 expected_revision | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SETTING_REVISION_REQUIRED。负责模块:sysadmin。 |
| `SETTING_STORE_UNAVAILABLE` | 503 | transient | sysadmin | 是 | 设置存储不可用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SETTING_STORE_UNAVAILABLE。负责模块:sysadmin。 |
| `SETTING_UNKNOWN` | 400 | fatal | sysadmin | 否 | 未知的设置键 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SETTING_UNKNOWN。负责模块:sysadmin。 |
| `STREAMING_UNSUPPORTED` | 400 | fatal | server | 否 | streaming unsupported | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 STREAMING_UNSUPPORTED。负责模块:server。 |
| `STREAMING_UNSUPPORTED_BY_UNDERLYING_CONNECTION` | 400 | fatal | server | 否 | streaming unsupported by underlying connection | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 STREAMING_UNSUPPORTED_BY_UNDERLYING_CONNECTION。负责模块:server。 |
| `SWBOOT_MERGE_FORBIDDEN` | 403 | fatal | swboot | 否 | 受控执行不会合并、推送或部署 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SWBOOT_MERGE_FORBIDDEN。负责模块:swboot。 |
| `SWBOOT_PATCH_DISABLED` | 403 | fatal | swboot | 否 | 受控补丁执行未开启 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SWBOOT_PATCH_DISABLED。负责模块:swboot。 |
| `SWBOOT_PATCH_UNIMPLEMENTED` | 501 | fatal | swboot | 否 | 受控补丁执行尚未放量 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SWBOOT_PATCH_UNIMPLEMENTED。负责模块:swboot。 |
| `SWBOOT_REPO_NOT_ALLOWLISTED` | 403 | fatal | swboot | 否 | 仓库路径不在允许列表 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 SWBOOT_REPO_NOT_ALLOWLISTED。负责模块:swboot。 |
| `TASKRUN_ALREADY_ACTIVE` | 409 | resumable | taskrun | 是 | 任务已在执行 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_ALREADY_ACTIVE。负责模块:taskrun。 |
| `TASKRUN_BACKGROUND` | 500 | transient | taskrun | 是 | 任务后台处理失败 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_BACKGROUND。负责模块:taskrun。 |
| `TASKRUN_CLEANUP_BLOCKED` | 409 | fatal | taskrun | 否 | 任务清理尚未满足核对条件 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_CLEANUP_BLOCKED。负责模块:taskrun。 |
| `TASKRUN_IDEMPOTENCY_CONFLICT` | 409 | resumable | taskrun | 是 | 重复的任务提交 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_IDEMPOTENCY_CONFLICT。负责模块:taskrun。 |
| `TASKRUN_NOT_RESUMABLE` | 409 | fatal | taskrun | 否 | 该任务不能继续 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_NOT_RESUMABLE。负责模块:taskrun。 |
| `TASKRUN_SENSITIVE_PAYLOAD` | 400 | fatal | taskrun | 否 | 任务输入包含不允许的内容 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_SENSITIVE_PAYLOAD。负责模块:taskrun。 |
| `TASKRUN_STORE_UNAVAILABLE` | 503 | transient | taskrun | 是 | 任务存储不可用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_STORE_UNAVAILABLE。负责模块:taskrun。 |
| `TASKRUN_TERMINAL_CONFLICT` | 409 | fatal | taskrun | 否 | 任务已结束,不能再次收口 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_TERMINAL_CONFLICT。负责模块:taskrun。 |
| `TASKRUN_UNAUTHORIZED_BACKEND` | 403 | fatal | taskrun | 否 | 任务执行器未授权 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASKRUN_UNAUTHORIZED_BACKEND。负责模块:taskrun。 |
| `TASK_INTERRUPTED` | 409 | resumable | taskrun | 是 | 任务执行中断,可继续或重新开始 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASK_INTERRUPTED。负责模块:taskrun。 |
| `TASK_KEY` | 400 | fatal | server | 否 | 客户端不可覆盖 task_key | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASK_KEY。负责模块:server。 |
| `TASK_SCOPE_UNRESOLVED` | 400 | fatal | projectinstance | 否 | 无法确定任务所属阶段 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TASK_SCOPE_UNRESOLVED。负责模块:projectinstance。 |
| `TURN_NOT_FOUND` | 404 | fatal | server | 否 | turn not found | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 TURN_NOT_FOUND。负责模块:server。 |
| `UNAUTHORIZED_ACTOR` | 403 | fatal | docgov | 否 | 当前身份不能执行该文档动作 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 UNAUTHORIZED_ACTOR。负责模块:docgov。 |
| `UNKNOWN_WORKFLOW` | 400 | fatal | server | 否 | unknown workflow | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 UNKNOWN_WORKFLOW。负责模块:server。 |
| `UNSUPPORTED_CATEGORY` | 400 | fatal | server | 否 | unsupported category | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 UNSUPPORTED_CATEGORY。负责模块:server。 |
| `UPLOAD_TOO_LARGE` | 400 | fatal | server | 否 | upload too large | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 UPLOAD_TOO_LARGE。负责模块:server。 |
| `USER_ID_AND_PROJECT_ROLE_REQUIRED` | 400 | fatal | server | 否 | user_id and project_role required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 USER_ID_AND_PROJECT_ROLE_REQUIRED。负责模块:server。 |
| `USER_ID_QUERY_REQUIRED` | 400 | fatal | server | 否 | user_id query required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 USER_ID_QUERY_REQUIRED。负责模块:server。 |
| `USER_ID_REQUIRED` | 400 | fatal | server | 否 | user_id required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 USER_ID_REQUIRED。负责模块:server。 |
| `VERSION_CONFLICT` | 409 | resumable | server | 是 | 资源版本冲突 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 VERSION_CONFLICT。负责模块:server。 |
| `WORKFLOW_CATALOG_UNAVAILABLE` | 503 | transient | server | 是 | workflow catalog unavailable | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_CATALOG_UNAVAILABLE。负责模块:server。 |
| `WORKFLOW_CONCURRENT_CONFLICT` | 409 | resumable | projectworkflow | 是 | 工作流选择版本落后或并发冲突,请刷新后重试 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_CONCURRENT_CONFLICT。负责模块:projectworkflow。 |
| `WORKFLOW_FORBIDDEN_NOT_CREATOR` | 403 | fatal | projectworkflow | 否 | 仅项目创建人有权切换激活工作流 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_FORBIDDEN_NOT_CREATOR。负责模块:projectworkflow。 |
| `WORKFLOW_INVALID_INPUT` | 400 | fatal | projectworkflow | 否 | 工作流参数无效 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_INVALID_INPUT。负责模块:projectworkflow。 |
| `WORKFLOW_KEY_IS_REQUIRED` | 400 | fatal | server | 否 | workflow_key is required | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_KEY_IS_REQUIRED。负责模块:server。 |
| `WORKFLOW_LEGACY_SWITCH_BLOCKED` | 409 | fatal | projectworkflow | 否 | 请通过项目设置完成工作流规划与激活 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_LEGACY_SWITCH_BLOCKED。负责模块:projectworkflow。 |
| `WORKFLOW_NOT_FOUND` | 404 | fatal | projectworkflow | 否 | 工作流定义不存在或未在项目启用 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_NOT_FOUND。负责模块:projectworkflow。 |
| `WORKFLOW_PREREQUISITE` | 400 | fatal | projectworkflow | 否 | 上游文档尚未满足,不能继续 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_PREREQUISITE。负责模块:projectworkflow。 |
| `WORKFLOW_UNAUTHENTICATED` | 401 | fatal | projectworkflow | 否 | 需要登录后操作工作流 | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKFLOW_UNAUTHENTICATED。负责模块:projectworkflow。 |
| `WORKSPACE_NOT_AVAILABLE_FOR_RUN` | 503 | transient | server | 是 | workspace not available for run | 用 request_id 在服务日志或管理台「错误审计」中检索错误码 WORKSPACE_NOT_AVAILABLE_FOR_RUN。负责模块:server。 |
+1
View File
@@ -290,6 +290,7 @@
| 文档 | 说明 |
|------|------|
| [./2026-09-22-t20-hardening-design.md](./2026-09-22-t20-hardening-design.md) | T20 W4 设计决策 SSOT(本目录仅留迁址桩);配套 [任务](../ops/short-term/2026-09-22-t20-design-items.md) |
| [./2026-09-28-error-code-catalog.md](./2026-09-28-error-code-catalog.md) | 统一错误码、结构化错误日志与 `error_events` 审计;配套 [任务](../ops/short-term/2026-09-28-error-code-observability.md) |
## 🔗 相关资源
+14 -1
View File
@@ -84,6 +84,7 @@ imports:
ctxmgr:
- app/uow
- docgov
- errcode
- replyexport
- storage
conflictresolve:
@@ -115,6 +116,8 @@ imports:
- ctxmgr
- docgov
- docrevision
- errcode
- errorevents
- events
- freedoc
- intent
@@ -247,6 +250,8 @@ imports:
- docgov
- docquery
- docrevision
- errcode
- errorevents
- events
- freedoc
- handoff
@@ -284,7 +289,8 @@ imports:
- workflowdef
storage:
- config
stream: []
stream:
- errcode
stream/transport:
- stream
swboot: []
@@ -293,6 +299,7 @@ imports:
- artifactvault
- bizdev
- ctxmgr
- errcode
- events
- projectworkflow
- stream
@@ -313,8 +320,12 @@ imports:
- workflowdef
workspace:
- app/uow
errcode: []
errorevents:
- errcode
events:
- app/uow
- errcode
artifactvault:
- app/uow
- fsutil
@@ -463,6 +474,8 @@ table_owners:
- sysadmin
system_setting_policy_audits:
- sysadmin
error_events:
- errorevents
maintenance_windows:
- maintenance
# FIND-ARCH-01 回归补登记(2026-09-26):以下为既有写者的真实表。
@@ -0,0 +1,91 @@
# 统一错误码与错误审计 测试报告
## 一、 测试背景 (Background)
HTTP 错误以前经 `writeSafeError` 只返回信封,不写服务端日志,`request_id` 无法和失败原因对照。本轮加入 `internal/errcode` 注册表、结构化错误日志、`error_events` 审计和管理台查询。
## 二、 测试目标 (Test Objectives)
- 错误码唯一,公开文案不包含内部片段。
- 错误日志带 `code` 与 `request_id`,不写调用方原文。
- 处理器显式给出的 HTTP 状态保持不变;信封 `code` 使用稳定细码。
- 流式分类优先读取类型化错误码。
- 管理台错误审计在无 `system:audit:read` 时拒绝。
- 模块边界门禁、`go vet`、`go test -short`、前端测试与构建通过。
- 产品 PI 全链路不在本次范围。
## 三、 测试方案与环境 (Test Plan & Environment)
- OS:Linux。Go 模块 `chat-agent-system`。工作树 `feature/error-code-observability-2026-09-28`,基线 `3e534bbb39d0d957e0b720a4ca06f22dd15959e6`。
- 前端:`web/` 内 `npm ci` 后执行 `npm test` 与 `npm run build`。
- 层级:工程单测与前端单测。隔离 PostgreSQL 需要 `TEST_DATABASE_URL`,本次环境未设置。
- 产品 Runner 全链路:NOT REQUIRED。本次不改变 PI 协议,只改变错误分类、日志和审计查询。
## 四、 预期结果 (Expected Results)
- `arch-check --mode block` 通过。
- `go test -short ./...` 通过。重复用户名注册仍是 HTTP 400,信封码为 `AUTH_USERNAME_EXISTS`。
- `TestStoreRoundTrip` 在没有测试库时 SKIP,不得连开发库。
- 前端 `npm test` 0 fail,`npm run build` 退出码 0。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 1. 模块边界
- 输入:`./scripts/arch-check.sh --mode block`
- 实际输出:`arch-check block: production gate passed`(`internal/archtest` 两次 `ok`,0.217s 与 0.197s)
- 判定:PASS
### 2. 注册表、日志、分类与异步包
- 输入:`go test -count=1 -timeout 180s ./internal/errcode ./internal/httpx ./internal/stream ./internal/errorevents ./internal/events ./internal/taskrun ./internal/ctxmgr`
- 实际输出:上述包全部 `ok`(errcode/httpx/stream/errorevents 约 0.003s,taskrun 0.172s,ctxmgr 0.104s)
- 判定:PASS
### 3. 全量 short 测试的第一轮
- 输入:`go vet ./... && go test -short -count=1 -timeout 20m ./...`
- 实际输出:`go vet` 无诊断。随后仅 `TestAuthHandlers_RegisterDuplicateUsername` 失败:`duplicate username must be 400, got 409`。日志为 `http_error code=AUTH_USERNAME_EXISTS status=409`。其余已跑完的包为 `ok`。
- 判定:FAIL。原因是注册表建议状态覆盖了处理器传入的 400。
### 4. 保持处理器状态后的第二轮
- 输入:同一条 `go test -short ./...`
- 实际输出:`http_error code=AUTH_USERNAME_EXISTS status=400`。断言改为检查细码前,失败信息是 `expected VALIDATION, got "AUTH_USERNAME_EXISTS"`。
- 判定:FAIL。HTTP 状态已恢复为 400。失败点是旧测试仍要求粗码 `VALIDATION`。
### 5. 更新测试期望后的 server 包
- 输入:`go test -short -count=1 -timeout 10m ./internal/server`
- 实际输出:`ok chat-agent-system/internal/server 20.288s`
- 判定:PASS。重复用户名单测单独复跑亦为 `ok`(0.175s)。
### 6. 审计表集成
- 输入:`go test -short -count=1 -v ./internal/errorevents -run TestStoreRoundTrip`
- 实际输出:`TEST_DATABASE_URL not set; skipping PostgreSQL integration test`,`--- SKIP: TestStoreRoundTrip (0.00s)`,包结果 `PASS`
- 判定:SKIP。未连接开发库,不能把跳过记成审计写入 PASS。
### 7. 前端
- 输入:`npm ci`,然后 `npm test && npm run build`
- 实际输出:`added 245 packages`。node:test `tests 526` / `pass 526` / `fail 0`。vitest `Test Files 3 passed` / `Tests 34 passed`。构建结束 `precompress: wrote 66 .br and 66 .gz`,退出码 0。
- 判定:PASS
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 检查 | 结果 |
|---|---|
| `./scripts/arch-check.sh --mode block` | PASS |
| `go vet ./...` | PASS(无诊断后进入测试) |
| 相关包单测(errcode、httpx、stream、events、taskrun、ctxmgr) | PASS |
| `go test -short ./internal/server`(修复期望后) | PASS,20.288s |
| `go test -short ./...` 第一轮 | FAIL,1 个用例;修复后未整库重跑,变更只影响 server 错误状态与一条断言,server 包已重跑通过 |
| `TestStoreRoundTrip` | SKIP(无 `TEST_DATABASE_URL`) |
| `npm test` | PASS,526 + 34 |
| `npm run build` | PASS |
| 产品 PI 全链路 | NOT REQUIRED |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
授权范围内的工程验证已达到合入前门槛。错误审计的 PostgreSQL 往返要在提供隔离测试库后补跑 `TestStoreRoundTrip`。部署验证不在本次授权内。
@@ -0,0 +1,36 @@
-- +goose Up
-- 统一错误事件审计:HTTP 与异步失败按 code + request_id 检索。
CREATE TABLE IF NOT EXISTS error_events (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
class TEXT NOT NULL DEFAULT 'fatal',
http_status INT,
request_id TEXT,
session_id TEXT,
workspace_id TEXT,
module TEXT,
phase_id TEXT,
path TEXT,
method TEXT,
source TEXT NOT NULL DEFAULT 'http',
upstream TEXT,
actor_user_id BIGINT,
retryable BOOLEAN NOT NULL DEFAULT false,
user_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_error_events_request_id
ON error_events (request_id);
CREATE INDEX IF NOT EXISTS idx_error_events_code_created
ON error_events (code, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_error_events_created
ON error_events (created_at DESC);
-- +goose Down
DROP INDEX IF EXISTS idx_error_events_created;
DROP INDEX IF EXISTS idx_error_events_code_created;
DROP INDEX IF EXISTS idx_error_events_request_id;
DROP TABLE IF EXISTS error_events;
+11
View File
@@ -26,6 +26,8 @@ import (
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/errcode"
"chat-agent-system/internal/errorevents"
"chat-agent-system/internal/events"
"chat-agent-system/internal/freedoc"
"chat-agent-system/internal/intent"
@@ -184,6 +186,9 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
intentflow.SetLLMSegmentProposer(&PiSegmentProposer{Backend: dialogueBackend, Model: model, DataDir: dataDir})
}
pool := parts.Pool
if pool != nil {
errorevents.Install(errorevents.NewStore(pool))
}
stores, storeErr := auth.NewStores(context.Background(), pool, cfg)
if storeErr != nil {
return nil, fmt.Errorf("auth store: %w", storeErr)
@@ -608,6 +613,12 @@ func (r *Runtime) runStageAdvanceReminders(ctx context.Context) {
n, err := r.Notify.ProcessStageAdvanceReminders(ctx)
if err != nil {
slog.Warn("stage advance reminder scan failed", "error", err)
errcode.Report(ctx, errcode.Event{
Code: "REMINDER_SCAN_FAILED",
Module: "notification",
Source: "async",
Upstream: err.Error(),
})
return
}
if n > 0 {
+19
View File
@@ -0,0 +1,19 @@
package ctxmgr
import (
"context"
"chat-agent-system/internal/errcode"
)
func reportBackground(err error) {
if err == nil {
return
}
errcode.Report(context.Background(), errcode.Event{
Code: "CTXMGR_BACKGROUND",
Module: "ctxmgr",
Source: "async",
Upstream: err.Error(),
})
}
+4
View File
@@ -400,6 +400,7 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_recent_chats_scan_incomplete", "error", err)
reportBackground(err)
}
}
} else {
@@ -432,6 +433,7 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_user_activities_scan_incomplete", "error", err)
reportBackground(err)
}
}
} else {
@@ -506,6 +508,7 @@ func (m *ContextManager) GetProjectContext(ctx context.Context, projectID int64)
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_project_activities_scan_incomplete", "error", err)
reportBackground(err)
}
pCtx.AllActivities = ApplyTimeDecayToActivities(rawActs, m.decayCfg)
}
@@ -755,6 +758,7 @@ func (m *ContextManager) ReflectUserPreference(ctx context.Context, userID int64
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_reflect_msgs_scan_incomplete", "error", err)
reportBackground(err)
}
}
} else {
+1
View File
@@ -73,6 +73,7 @@ func (m *ContextManager) EnqueueSemanticShadow(ctx context.Context, job Semantic
job.WatermarkToMessageID, job.PriorRevision, payload)
if err != nil {
slog.Warn("semantic shadow job persist failed; using in-process queue", "session_id", job.SessionID, "err", err)
reportBackground(err)
m.rememberShadowJob(job)
}
m.kickShadowWorker()
+2
View File
@@ -83,6 +83,7 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) (
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
slog.Warn("chat summary load failed", "session_id", sessionID, "err", err)
reportBackground(err)
}
}
@@ -162,6 +163,7 @@ func (m *ContextManager) SaveChatSummary(ctx context.Context, sum *ChatSummary)
noteSummaryNotDurable(sum)
m.rememberSummary(sum)
slog.Warn("chat summary persist failed", "session_id", sum.SessionID, "err", err)
reportBackground(err)
return fmt.Errorf("chat summary persist failed: %w", err)
}
sum.Persisted = true
+9
View File
@@ -97,6 +97,13 @@ func (e *ContentError) Unwrap() error {
return e.cause
}
func (e *ContentError) ErrorCode() string {
if e == nil || e.Code == "" {
return "DOCUMENT_CONTENT_ERROR"
}
return e.Code
}
func NewContentError(code string, cause error, retryable, needsAdmin bool) *ContentError {
msg := code
if cause != nil {
@@ -125,6 +132,8 @@ func (e *DocumentBusyError) Error() string {
func (e *DocumentBusyError) Unwrap() error { return ErrDocumentBusy }
func (e *DocumentBusyError) ErrorCode() string { return "DOC_BUSY" }
// DocumentConfig 受控文档流转与退回配置
type DocumentConfig struct {
RejectRollbackTo DocumentState `json:"reject_rollback_to"` // 驳回退回目标: drafting 或 reviewing
+414
View File
@@ -0,0 +1,414 @@
// Package errcode 是全系统错误码注册表。
// 领域包保留哨兵错误;server 把它们绑定到这里的稳定码。
// 日志与 error_events 审计只记录码、request_id 和脱敏后的摘要。
package errcode
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
ClassTransient = "transient"
ClassResumable = "resumable"
ClassFatal = "fatal"
ClassCancelled = "cancelled"
)
// Def 是一条可检索的错误码。Message 与 Guide 必须是可公开文案。
type Def struct {
ID string
Status int
Message string
Class string
Module string
Guide string
Retryable bool
Actions []string
}
// Event 是一条错误审计记录。Upstream 在 Report 时脱敏。
type Event struct {
ID int64 `json:"id,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Code string `json:"code"`
Class string `json:"class,omitempty"`
HTTPStatus int `json:"httpStatus,omitempty"`
RequestID string `json:"requestId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
WorkspaceID string `json:"workspaceId,omitempty"`
Module string `json:"module,omitempty"`
PhaseID string `json:"phaseId,omitempty"`
Path string `json:"path,omitempty"`
Method string `json:"method,omitempty"`
Source string `json:"source,omitempty"`
Upstream string `json:"upstream,omitempty"`
ActorUserID int64 `json:"actorUserId,omitempty"`
Retryable bool `json:"retryable,omitempty"`
UserMessage string `json:"userMessage,omitempty"`
}
// Auditor 持久化错误事件。写入失败不得影响主请求。
type Auditor interface {
Record(ctx context.Context, ev Event) error
}
var (
mu sync.RWMutex
byID = map[string]Def{}
legacyIndex = map[string]string{}
prefixes []prefixRule
sentinels []sentinel
auditor Auditor
)
type prefixRule struct {
prefix string
id string
}
type sentinel struct {
err error
id string
}
func register(d Def) Def {
if d.ID == "" {
panic("errcode: empty id")
}
if d.Class == "" {
d.Class = ClassFatal
}
if d.Message == "" {
d.Message = "请求无法完成"
}
if d.Guide == "" {
mod := d.Module
if mod == "" {
mod = "server"
}
d.Guide = fmt.Sprintf("用 request_id 在服务日志或管理台「错误审计」中检索错误码 %s。负责模块:%s。", d.ID, mod)
}
if d.Class == ClassTransient || d.Class == ClassResumable {
d.Retryable = true
}
mu.Lock()
defer mu.Unlock()
if _, ok := byID[d.ID]; ok {
panic("errcode: duplicate id " + d.ID)
}
byID[d.ID] = d
return d
}
func registerLegacy(message, id string) {
message = strings.TrimSpace(message)
if message == "" || id == "" {
panic("errcode: empty legacy mapping")
}
mu.Lock()
defer mu.Unlock()
if _, ok := byID[id]; !ok {
panic("errcode: legacy maps to unknown id " + id)
}
if prev, ok := legacyIndex[message]; ok && prev != id {
panic("errcode: legacy message remapped " + message)
}
legacyIndex[message] = id
}
func registerPrefix(prefix, id string) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
mu.Lock()
defer mu.Unlock()
if _, ok := byID[id]; !ok {
panic("errcode: prefix maps to unknown id " + id)
}
prefixes = append(prefixes, prefixRule{prefix: prefix, id: id})
}
// Bind 把领域哨兵错误绑到已注册的码上。后绑定的不覆盖先绑定的 errors.Is 命中。
func Bind(err error, id string) {
if err == nil {
panic("errcode: bind nil")
}
mu.Lock()
defer mu.Unlock()
if _, ok := byID[id]; !ok {
panic("errcode: bind unknown id " + id)
}
sentinels = append(sentinels, sentinel{err: err, id: id})
}
// Lookup 按码查询。
func Lookup(id string) (Def, bool) {
mu.RLock()
defer mu.RUnlock()
d, ok := byID[id]
return d, ok
}
// All 返回全部错误码,按 ID 排序。
func All() []Def {
mu.RLock()
defer mu.RUnlock()
out := make([]Def, 0, len(byID))
for _, d := range byID {
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// FromStatus 把 HTTP 状态映射到粗粒度但稳定的码。
func FromStatus(status int) Def {
id := "REQUEST_INVALID"
switch status {
case 401:
id = "AUTH_UNAUTHENTICATED"
case 403:
id = "AUTH_FORBIDDEN"
case 404:
id = "RESOURCE_NOT_FOUND"
case 405:
id = "REQUEST_METHOD_NOT_ALLOWED"
case 409:
id = "RESOURCE_CONFLICT"
case 429:
id = "RATE_LIMITED"
case 503:
id = "SERVICE_UNAVAILABLE"
default:
if status >= 500 {
id = "INTERNAL"
}
}
d, ok := Lookup(id)
if !ok {
return Def{ID: id, Status: status, Message: "请求无法完成", Class: ClassFatal, Module: "server"}
}
return d
}
// FromLegacy 用公开文案(或其中的 JSON message/error)选择详细码。
// 未命中时退回 FromStatus,不改变调用方的 HTTP 状态。
func FromLegacy(status int, legacy string) Def {
key := normalizeLegacy(legacy)
mu.RLock()
id, ok := legacyIndex[key]
var pref string
if !ok {
low := strings.ToLower(key)
for _, p := range prefixes {
if strings.HasPrefix(low, p.prefix) {
pref = p.id
break
}
}
}
mu.RUnlock()
if ok {
if d, found := Lookup(id); found {
return d
}
}
if pref != "" {
if d, found := Lookup(pref); found {
return d
}
}
return FromStatus(status)
}
func normalizeLegacy(legacy string) string {
legacy = strings.TrimSpace(legacy)
if legacy == "" || !strings.HasPrefix(legacy, "{") {
return legacy
}
var m map[string]any
if err := json.Unmarshal([]byte(legacy), &m); err != nil {
return legacy
}
if msg, _ := m["message"].(string); strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg)
}
if msg, _ := m["error"].(string); strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg)
}
return legacy
}
// Error 是带稳定码的错误,可被 FromError / 流式分类直接读取。
type Error struct {
Def Def
Cause error
}
func (e *Error) Error() string {
if e == nil {
return ""
}
if e.Cause != nil {
return e.Cause.Error()
}
if e.Def.Message != "" {
return e.Def.Message
}
return e.Def.ID
}
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
func (e *Error) ErrorCode() string {
if e == nil {
return ""
}
return e.Def.ID
}
// New 包装一个已注册错误码。
func New(id string, cause error) error {
d, ok := Lookup(id)
if !ok {
d = Def{ID: id, Message: "请求无法完成", Class: ClassFatal, Module: "server"}
}
return &Error{Def: d, Cause: cause}
}
type errorCoder interface {
ErrorCode() string
}
// FromError 读取 typed 错误码或已绑定的哨兵错误。
func FromError(err error) (Def, bool) {
if err == nil {
return Def{}, false
}
var coded *Error
if errors.As(err, &coded) && coded != nil && coded.Def.ID != "" {
if d, ok := Lookup(coded.Def.ID); ok {
return d, true
}
return coded.Def, true
}
var coder errorCoder
if errors.As(err, &coder) && coder != nil {
if d, ok := Lookup(coder.ErrorCode()); ok {
return d, true
}
}
mu.RLock()
defer mu.RUnlock()
for _, s := range sentinels {
if errors.Is(err, s.err) {
d, ok := byID[s.id]
return d, ok
}
}
return Def{}, false
}
// SetAuditor 安装进程级审计写入器。未安装时 Report 只跳过落库。
func SetAuditor(a Auditor) {
mu.Lock()
auditor = a
mu.Unlock()
}
// Report 记录一条错误审计。落库失败只打日志,不向调用方返回错误。
func Report(ctx context.Context, ev Event) {
_ = ctx
if ev.Code == "" {
ev.Code = "INTERNAL"
}
if ev.Class == "" {
if d, ok := Lookup(ev.Code); ok {
ev.Class = d.Class
ev.Retryable = d.Retryable
if ev.UserMessage == "" {
ev.UserMessage = d.Message
}
if ev.Module == "" {
ev.Module = d.Module
}
} else {
ev.Class = ClassFatal
}
}
ev.Upstream = Sanitize(ev.Upstream)
ev.UserMessage = Sanitize(ev.UserMessage)
ev.Path = stripQuery(ev.Path)
if ev.Source == "" {
ev.Source = "http"
}
mu.RLock()
a := auditor
mu.RUnlock()
if a == nil {
return
}
cctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if err := a.Record(cctx, ev); err != nil {
slog.Warn("error_event_audit_degraded", "code", ev.Code, "request_id", ev.RequestID, "err", err.Error())
}
}
// Sanitize 去掉疑似内部信息,并截断到 160 个字符。
func Sanitize(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if looksInternal(s) {
return ""
}
return truncate(s, 160)
}
func looksInternal(s string) bool {
low := strings.ToLower(s)
needles := []string{
"sql", "pq:", "pgx", "postgres", "stack", "panic", "token", "password",
"secret", "api_key", "bearer ", "../", "prompt", "commit", "rollback",
"transaction", "constraint", "violates",
}
for _, n := range needles {
if strings.Contains(low, n) {
return true
}
}
if strings.Contains(s, "/home/") || strings.Contains(s, "/var/") || strings.Contains(s, "\\") {
return true
}
return false
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
r := []rune(s)
return string(r[:maxRunes])
}
func stripQuery(path string) string {
path = strings.TrimSpace(path)
if i := strings.IndexAny(path, "?#"); i >= 0 {
path = path[:i]
}
return path
}
+64
View File
@@ -0,0 +1,64 @@
package errcode
import (
"errors"
"strings"
"testing"
)
func TestRegistryUniqueAndPublic(t *testing.T) {
all := All()
if len(all) < 50 {
t.Fatalf("catalog too small: %d", len(all))
}
seen := map[string]struct{}{}
for _, d := range all {
if _, ok := seen[d.ID]; ok {
t.Fatalf("duplicate %s", d.ID)
}
seen[d.ID] = struct{}{}
if d.Message == "" || d.Guide == "" || d.Class == "" {
t.Fatalf("incomplete %+v", d)
}
if Sanitize(d.Message) != d.Message || Sanitize(d.Guide) != d.Guide {
t.Fatalf("public text redacted: %+v", d)
}
if strings.Contains(d.Guide, "/home/") || strings.Contains(strings.ToLower(d.Message), "password") {
t.Fatalf("leak %+v", d)
}
}
if _, ok := Lookup("INTERNAL"); !ok {
t.Fatal("missing INTERNAL")
}
if got := FromStatus(404).ID; got != "RESOURCE_NOT_FOUND" {
t.Fatalf("404 -> %s", got)
}
if got := FromLegacy(400, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`).ID; got != "REQUEST_JSON_INVALID" {
t.Fatalf("json legacy -> %s", got)
}
if got := FromLegacy(400, "invalid request body: boom").ID; got != "REQUEST_BODY_INVALID" {
t.Fatalf("prefix -> %s", got)
}
}
func TestFromErrorSentinelAndTyped(t *testing.T) {
sentinel := errors.New("errcode test sentinel")
Bind(sentinel, "REQUEST_INVALID")
def, ok := FromError(sentinel)
if !ok || def.ID != "REQUEST_INVALID" {
t.Fatalf("sentinel %+v ok=%v", def, ok)
}
wrapped := New("DOC_BUSY", errors.New("编辑中"))
def, ok = FromError(wrapped)
if !ok || def.ID != "DOC_BUSY" {
t.Fatalf("typed %+v", def)
}
if Sanitize("pq: relation users password /home/x") != "" {
t.Fatal("expected redact")
}
}
func TestReportWithoutAuditor(t *testing.T) {
SetAuditor(nil)
Report(t.Context(), Event{Code: "INTERNAL", RequestID: "req_test", Upstream: "pq: secret"})
}
+233
View File
@@ -0,0 +1,233 @@
package errcode
func init() {
rows := []Def{
{ID: "REQUEST_INVALID", Status: 400, Message: "请求参数无效", Module: "server"},
{ID: "REQUEST_JSON_INVALID", Status: 400, Message: "无效的 JSON 请求体", Module: "server"},
{ID: "REQUEST_BODY_INVALID", Status: 400, Message: "无效的请求体", Module: "server"},
{ID: "REQUEST_METHOD_NOT_ALLOWED", Status: 405, Message: "不支持的请求方法", Module: "server"},
{ID: "AUTH_UNAUTHENTICATED", Status: 401, Message: "未登录或登录已过期", Module: "auth"},
{ID: "AUTH_FORBIDDEN", Status: 403, Message: "无权访问", Module: "auth"},
{ID: "AUTH_SESSION_EXPIRED", Status: 401, Message: "未登录或登录已过期", Module: "auth"},
{ID: "AUTH_LOGIN_REQUIRED", Status: 401, Message: "请先登录", Module: "auth"},
{ID: "AUTH_USERNAME_EXISTS", Status: 409, Message: "用户名已存在", Module: "auth"},
{ID: "AUTH_BAD_CREDENTIALS", Status: 401, Message: "用户名或密码错误", Module: "auth"},
{ID: "RESOURCE_NOT_FOUND", Status: 404, Message: "资源不存在", Module: "server"},
{ID: "RESOURCE_CONFLICT", Status: 409, Message: "资源版本冲突", Module: "server", Class: ClassResumable},
{ID: "VERSION_CONFLICT", Status: 409, Message: "资源版本冲突", Module: "server", Class: ClassResumable},
{ID: "IDEMPOTENCY_CONFLICT", Status: 409, Message: "重复提交", Module: "server", Class: ClassResumable},
{ID: "SERVICE_UNAVAILABLE", Status: 503, Message: "服务暂不可用", Module: "server", Class: ClassTransient},
{ID: "INTERNAL", Status: 500, Message: "请求无法完成", Module: "server"},
{ID: "INTERNAL_ERROR", Status: 500, Message: "系统内部错误,请重新开始会话", Module: "server", Actions: []string{"restart"}},
{ID: "RATE_LIMITED", Status: 429, Message: "请求过于频繁,请稍后重试", Module: "server", Class: ClassTransient, Actions: []string{"retry"}},
{ID: "DOCUMENT_NOT_FOUND", Status: 404, Message: "文档不存在", Module: "docgov"},
{ID: "DOCUMENT_LOCKED", Status: 409, Message: "文档已锁定", Module: "docgov", Class: ClassResumable},
{ID: "DOC_BUSY", Status: 409, Message: "该文档正在编辑中,请等待现有任务完成", Module: "docgov", Class: ClassFatal, Actions: []string{}},
{ID: "DOC_LEDGER_ERROR", Status: 500, Message: "文档职责账本写入失败,请重新开始会话", Module: "docgov", Actions: []string{"restart"}},
{ID: "DOC_TASK_BUSY", Status: 409, Message: "文档任务正在执行,暂不能改派", Module: "projectinstance", Class: ClassResumable},
{ID: "LEASE_NOT_HELD", Status: 409, Message: "当前没有文档编辑租约", Module: "docgov", Class: ClassResumable},
{ID: "LEASE_EXPIRED", Status: 409, Message: "文档编辑租约已过期", Module: "docgov", Class: ClassResumable},
{ID: "LEASE_STALE", Status: 409, Message: "文档编辑租约已失效", Module: "docgov", Class: ClassResumable},
{ID: "FORCE_EDIT_NOT_ALLOWED", Status: 409, Message: "当前不允许强制直接编辑", Module: "docgov"},
{ID: "PROPOSAL_NOT_FOUND", Status: 404, Message: "变更提案不存在", Module: "docgov"},
{ID: "UNAUTHORIZED_ACTOR", Status: 403, Message: "当前身份不能执行该文档动作", Module: "docgov"},
{ID: "AUTHOR_ONLY_EDIT", Status: 403, Message: "只有编制人可以修改文档内容", Module: "docgov"},
{ID: "REVIEWER_CANNOT_EDIT", Status: 403, Message: "审核人不能修改文档内容", Module: "docgov"},
{ID: "APPROVER_CANNOT_EDIT", Status: 403, Message: "批准人不能修改文档内容", Module: "docgov"},
{ID: "INVALID_STATE_TRANSITION", Status: 409, Message: "文档状态不能这样流转", Module: "docgov"},
{ID: "REJECT_REASON_REQUIRED", Status: 400, Message: "驳回必须填写原因", Module: "docgov"},
{ID: "PROPOSAL_ALREADY_DECIDED", Status: 409, Message: "变更提案已裁决", Module: "docgov"},
{ID: "CHANGE_PROPOSAL_PENDING", Status: 409, Message: "文档仍有待编制人确认的变更提案", Module: "docgov", Class: ClassResumable},
{ID: "DOCUMENT_CONTENT_PROJECTION_MISSING", Status: 409, Message: "文档内容投影缺失", Module: "docgov"},
{ID: "DOCUMENT_CONTENT_PROJECTION_EMPTY", Status: 409, Message: "文档内容投影为空", Module: "docgov"},
{ID: "DOCUMENT_CONTENT_INTEGRITY_MISMATCH", Status: 409, Message: "文档内容校验不一致", Module: "docgov"},
{ID: "DOCUMENT_CONTENT_PROJECTION_DRIFT", Status: 409, Message: "文档内容投影与账本不一致", Module: "docgov"},
{ID: "DOCUMENT_CONTENT_ERROR", Status: 409, Message: "文档内容读取失败", Module: "docgov"},
{ID: "CHAT_TIMEOUT", Status: 504, Message: "生成回复超时,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}},
{ID: "NETWORK_ERROR", Status: 502, Message: "网络连接异常,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}},
{ID: "RUNNER_RESULT_REJECTED", Status: 502, Message: "助手执行结果校验失败,请重新开始任务", Module: "stream", Actions: []string{"restart"}},
{ID: "TASK_INTERRUPTED", Status: 409, Message: "任务执行中断,可继续或重新开始", Module: "taskrun", Class: ClassResumable, Actions: []string{"continue", "restart"}},
{ID: "TASK_SCOPE_UNRESOLVED", Status: 400, Message: "无法确定任务所属阶段", Module: "projectinstance"},
{ID: "FREEDOC_NOT_FOUND", Status: 404, Message: "自由文档不存在", Module: "freedoc"},
{ID: "FREEDOC_FORBIDDEN", Status: 403, Message: "无权访问该自由文档", Module: "freedoc"},
{ID: "FREEDOC_CONFLICT", Status: 409, Message: "自由文档版本冲突", Module: "freedoc", Class: ClassResumable},
{ID: "FREEDOC_INVALID_PARAM", Status: 400, Message: "自由文档参数无效", Module: "freedoc"},
{ID: "FREEDOC_NOT_EXPORTABLE", Status: 400, Message: "该消息不能导出为文档", Module: "freedoc"},
{ID: "SETTING_UNKNOWN", Status: 400, Message: "未知的设置键", Module: "sysadmin"},
{ID: "SETTING_READ_ONLY", Status: 403, Message: "该设置为只读", Module: "sysadmin"},
{ID: "SETTING_INVALID_VALUE", Status: 400, Message: "设置值类型不匹配", Module: "sysadmin"},
{ID: "SETTING_STORE_UNAVAILABLE", Status: 503, Message: "设置存储不可用", Module: "sysadmin", Class: ClassTransient},
{ID: "SETTING_REVISION_REQUIRED", Status: 400, Message: "该设置需要 expected_revision", Module: "sysadmin"},
{ID: "SETTING_REVISION_CONFLICT", Status: 409, Message: "设置版本冲突,请刷新后重试", Module: "sysadmin", Class: ClassResumable},
{ID: "SETTING_NOT_FOUND", Status: 404, Message: "设置不存在", Module: "sysadmin"},
{ID: "WORKFLOW_UNAUTHENTICATED", Status: 401, Message: "需要登录后操作工作流", Module: "projectworkflow"},
{ID: "WORKFLOW_FORBIDDEN_NOT_CREATOR", Status: 403, Message: "仅项目创建人有权切换激活工作流", Module: "projectworkflow"},
{ID: "PROJECT_NOT_FOUND", Status: 404, Message: "项目不存在", Module: "projectworkflow"},
{ID: "WORKFLOW_NOT_FOUND", Status: 404, Message: "工作流定义不存在或未在项目启用", Module: "projectworkflow"},
{ID: "WORKFLOW_CONCURRENT_CONFLICT", Status: 409, Message: "工作流选择版本落后或并发冲突,请刷新后重试", Module: "projectworkflow", Class: ClassResumable},
{ID: "WORKFLOW_PREREQUISITE", Status: 400, Message: "上游文档尚未满足,不能继续", Module: "projectworkflow"},
{ID: "WORKFLOW_INVALID_INPUT", Status: 400, Message: "工作流参数无效", Module: "projectworkflow"},
{ID: "PROJECT_NAME_DUPLICATE", Status: 409, Message: "项目名称已存在", Module: "projectworkflow"},
{ID: "WORKFLOW_LEGACY_SWITCH_BLOCKED", Status: 409, Message: "请通过项目设置完成工作流规划与激活", Module: "projectworkflow"},
{ID: "TASKRUN_NOT_RESUMABLE", Status: 409, Message: "该任务不能继续", Module: "taskrun"},
{ID: "TASKRUN_ALREADY_ACTIVE", Status: 409, Message: "任务已在执行", Module: "taskrun", Class: ClassResumable},
{ID: "TASKRUN_TERMINAL_CONFLICT", Status: 409, Message: "任务已结束,不能再次收口", Module: "taskrun"},
{ID: "TASKRUN_CLEANUP_BLOCKED", Status: 409, Message: "任务清理尚未满足核对条件", Module: "taskrun"},
{ID: "TASKRUN_UNAUTHORIZED_BACKEND", Status: 403, Message: "任务执行器未授权", Module: "taskrun"},
{ID: "TASKRUN_SENSITIVE_PAYLOAD", Status: 400, Message: "任务输入包含不允许的内容", Module: "taskrun"},
{ID: "TASKRUN_STORE_UNAVAILABLE", Status: 503, Message: "任务存储不可用", Module: "taskrun", Class: ClassTransient},
{ID: "TASKRUN_IDEMPOTENCY_CONFLICT", Status: 409, Message: "重复的任务提交", Module: "taskrun", Class: ClassResumable},
{ID: "SESSION_OWNER_MISMATCH", Status: 403, Message: "无权访问该会话", Module: "ctxmgr"},
{ID: "CHAT_TURN_NOT_FOUND", Status: 404, Message: "对话回合不存在", Module: "ctxmgr"},
{ID: "IDEMPOTENCY_KEY_REUSED", Status: 409, Message: "重复提交的内容不一致", Module: "ctxmgr"},
{ID: "CTXMGR_BACKGROUND", Status: 500, Message: "上下文后台任务失败", Module: "ctxmgr", Class: ClassTransient},
{ID: "MAINTENANCE_NOT_FOUND", Status: 404, Message: "维护窗口不存在", Module: "maintenance"},
{ID: "MAINTENANCE_CONFLICT_OPEN", Status: 409, Message: "已有未结束的维护窗口", Module: "maintenance", Class: ClassResumable},
{ID: "MAINTENANCE_INVALID_WINDOW", Status: 400, Message: "维护窗口参数无效", Module: "maintenance"},
{ID: "MAINTENANCE_BAD_STATUS", Status: 409, Message: "当前状态不可取消", Module: "maintenance"},
{ID: "SWBOOT_MERGE_FORBIDDEN", Status: 403, Message: "受控执行不会合并、推送或部署", Module: "swboot"},
{ID: "SWBOOT_REPO_NOT_ALLOWLISTED", Status: 403, Message: "仓库路径不在允许列表", Module: "swboot"},
{ID: "SWBOOT_PATCH_DISABLED", Status: 403, Message: "受控补丁执行未开启", Module: "swboot"},
{ID: "SWBOOT_PATCH_UNIMPLEMENTED", Status: 501, Message: "受控补丁执行尚未放量", Module: "swboot"},
{ID: "EVENTS_QUARANTINE", Status: 500, Message: "事件已隔离,不会自动重试", Module: "events"},
{ID: "EVENTS_DEAD", Status: 500, Message: "事件投递已进入死信", Module: "events"},
{ID: "EVENTS_AUTH_FAILURE", Status: 403, Message: "事件未通过授权", Module: "events"},
{ID: "EVENTS_INTEGRITY", Status: 500, Message: "事件完整性校验失败", Module: "events"},
{ID: "EVENTS_STALE_FENCING", Status: 409, Message: "事件租约令牌已过期", Module: "events", Class: ClassResumable},
{ID: "REMINDER_SCAN_FAILED", Status: 500, Message: "阶段提醒扫描失败", Module: "notification", Class: ClassTransient},
{ID: "INSTANCE_NOT_AVAILABLE", Status: 503, Message: "项目实例服务不可用", Module: "projectinstance", Class: ClassTransient},
{ID: "INSTANCE_FORBIDDEN_NOT_CREATOR", Status: 403, Message: "仅项目创建人可配置工作流", Module: "projectinstance"},
{ID: "INSTANCE_PLAN_FROZEN", Status: 409, Message: "工作流已激活,规划已冻结", Module: "projectinstance"},
{ID: "INSTANCE_SWITCH_FROZEN", Status: 409, Message: "运行中不能切换工作流,只能调整职责、审批和文档启用", Module: "projectinstance"},
{ID: "INSTANCE_ALREADY_ACTIVE", Status: 409, Message: "项目已有运行中的工作流实例", Module: "projectinstance", Class: ClassResumable},
{ID: "INSTANCE_PLAN_RECONFIRM", Status: 409, Message: "规划已变更,请重新预览并确认", Module: "projectinstance", Class: ClassResumable},
{ID: "INSTANCE_VALIDATION_FAILED", Status: 400, Message: "工作流配置校验未通过", Module: "projectinstance"},
{ID: "INSTANCE_ACTIVATION_INVALID", Status: 400, Message: "激活令牌无效或已过期", Module: "projectinstance"},
{ID: "INSTANCE_CONFLICT", Status: 409, Message: "项目版本冲突,请刷新后重试", Module: "projectinstance", Class: ClassResumable},
{ID: "INSTANCE_PLAN_CONFLICT", Status: 409, Message: "规划版本冲突,请刷新后重试", Module: "projectinstance", Class: ClassResumable},
{ID: "INSTANCE_NOT_FOUND", Status: 404, Message: "工作流实例不存在", Module: "projectinstance"},
{ID: "INSTANCE_STAGE_GATE", Status: 400, Message: "阶段门禁未满足,必需文档尚未全部批准", Module: "projectinstance"},
{ID: "INSTANCE_STAGE_NOT_ACTIVE", Status: 409, Message: "目标阶段不是当前活动阶段", Module: "projectinstance"},
{ID: "INSTANCE_STAGE_NOT_COMPLETED", Status: 400, Message: "只能再打开已完成的阶段", Module: "projectinstance"},
{ID: "INSTANCE_RECHECK_REQUIRED", Status: 409, Message: "该阶段有待复核标记,推进前须确认已知悉", Module: "projectinstance"},
{ID: "INSTANCE_INVALID_STAGE", Status: 400, Message: "无效的阶段", Module: "projectinstance"},
{ID: "INSTANCE_REVISION_CONFLICT", Status: 409, Message: "实例版本冲突,请刷新后重试", Module: "projectinstance", Class: ClassResumable},
{ID: "INSTANCE_TERMINATION_DENIED", Status: 409, Message: "当前状态不允许终止", Module: "projectinstance"},
{ID: "INSTANCE_AUTHOR_ONLY_DISABLED", Status: 403, Message: "系统未授权编制人兼任审核批准", Module: "projectinstance"},
{ID: "INSTANCE_AUTHOR_ONLY_UNAVAILABLE", Status: 503, Message: "兼任总授权存储不可用", Module: "projectinstance", Class: ClassTransient},
{ID: "INSTANCE_AUTHOR_ONLY_CHANGED", Status: 409, Message: "兼任总授权已变更,请重新预览并确认激活", Module: "projectinstance", Class: ClassResumable},
{ID: "INSTANCE_DUTY_FORBIDDEN", Status: 403, Message: "无权改派文档职责", Module: "projectinstance"},
{ID: "INSTANCE_DOC_TASK_NOT_FOUND", Status: 404, Message: "文档任务不存在或已禁用", Module: "projectinstance"},
{ID: "INSTANCE_LEGACY_SWITCH_BLOCKED", Status: 409, Message: "新项目请通过项目设置完成工作流激活", Module: "projectinstance"},
{ID: "TASKRUN_BACKGROUND", Status: 500, Message: "任务后台处理失败", Module: "taskrun", Class: ClassTransient},
}
for _, d := range rows {
if d.Actions != nil && len(d.Actions) == 0 {
d.Actions = []string{}
}
register(d)
}
aliases := map[string]string{
"无效的 JSON 请求体": "REQUEST_JSON_INVALID",
"无效的 JSON": "REQUEST_JSON_INVALID",
"invalid json": "REQUEST_JSON_INVALID",
"无效的请求体": "REQUEST_BODY_INVALID",
"invalid request body": "REQUEST_BODY_INVALID",
"invalid body": "REQUEST_BODY_INVALID",
"不支持的请求方法": "REQUEST_METHOD_NOT_ALLOWED",
"不支持的方法": "REQUEST_METHOD_NOT_ALLOWED",
"method not allowed": "REQUEST_METHOD_NOT_ALLOWED",
"Method Not Allowed": "REQUEST_METHOD_NOT_ALLOWED",
"未登录或登录已过期": "AUTH_SESSION_EXPIRED",
"请先登录": "AUTH_LOGIN_REQUIRED",
"用户名或密码错误": "AUTH_BAD_CREDENTIALS",
"用户名已存在": "AUTH_USERNAME_EXISTS",
"未知的设置键": "SETTING_UNKNOWN",
"该设置为只读": "SETTING_READ_ONLY",
"设置值类型不匹配": "SETTING_INVALID_VALUE",
"设置存储不可用": "SETTING_STORE_UNAVAILABLE",
"该设置需要 expected_revision": "SETTING_REVISION_REQUIRED",
"设置版本冲突,请刷新后重试": "SETTING_REVISION_CONFLICT",
"已有未结束的维护窗口": "MAINTENANCE_CONFLICT_OPEN",
"当前状态不可取消": "MAINTENANCE_BAD_STATUS",
"无权改派文档职责": "INSTANCE_DUTY_FORBIDDEN",
"项目实例服务不可用": "INSTANCE_NOT_AVAILABLE",
"项目实例服务不可用,请先执行数据库迁移": "INSTANCE_NOT_AVAILABLE",
"仅项目创建人可配置工作流": "INSTANCE_FORBIDDEN_NOT_CREATOR",
"仅项目创建人有权切换激活工作流": "WORKFLOW_FORBIDDEN_NOT_CREATOR",
"工作流已激活,规划已冻结": "INSTANCE_PLAN_FROZEN",
"运行中不能切换工作流,只能调整职责、审批和文档启用": "INSTANCE_SWITCH_FROZEN",
"项目已有运行中的工作流实例": "INSTANCE_ALREADY_ACTIVE",
"项目已有运行中的工作流实例,请使用项目设置查看流程": "INSTANCE_ALREADY_ACTIVE",
"规划已变更,请重新预览并确认": "INSTANCE_PLAN_RECONFIRM",
"工作流配置校验未通过": "INSTANCE_VALIDATION_FAILED",
"激活令牌无效或已过期": "INSTANCE_ACTIVATION_INVALID",
"项目版本冲突,请刷新后重试": "INSTANCE_CONFLICT",
"工作流选择版本落后或并发冲突,请刷新后重试": "WORKFLOW_CONCURRENT_CONFLICT",
"规划版本冲突,请刷新后重试": "INSTANCE_PLAN_CONFLICT",
"工作流实例不存在": "INSTANCE_NOT_FOUND",
"阶段门禁未满足,必需文档尚未全部批准": "INSTANCE_STAGE_GATE",
"目标阶段不是当前活动阶段": "INSTANCE_STAGE_NOT_ACTIVE",
"只能再打开已完成的阶段": "INSTANCE_STAGE_NOT_COMPLETED",
"该阶段有待复核标记,推进前须确认已知悉": "INSTANCE_RECHECK_REQUIRED",
"无效的阶段": "INSTANCE_INVALID_STAGE",
"实例版本冲突,请刷新后重试": "INSTANCE_REVISION_CONFLICT",
"系统未授权编制人兼任审核批准": "INSTANCE_AUTHOR_ONLY_DISABLED",
"兼任总授权已变更,请重新预览并确认激活": "INSTANCE_AUTHOR_ONLY_CHANGED",
"兼任总授权存储不可用": "INSTANCE_AUTHOR_ONLY_UNAVAILABLE",
"文档任务不存在或已禁用": "INSTANCE_DOC_TASK_NOT_FOUND",
"新项目请通过项目设置完成工作流激活": "INSTANCE_LEGACY_SWITCH_BLOCKED",
"请通过项目设置完成工作流规划与激活": "WORKFLOW_LEGACY_SWITCH_BLOCKED",
"工作流定义不存在或未在项目启用": "WORKFLOW_NOT_FOUND",
"项目不存在": "PROJECT_NOT_FOUND",
"项目不存在或无权限访问": "PROJECT_NOT_FOUND",
"document not found": "DOCUMENT_NOT_FOUND",
"proposal not found": "PROPOSAL_NOT_FOUND",
"ERR_INVALID_PARAM": "REQUEST_INVALID",
"ERR_UNAUTHENTICATED": "AUTH_LOGIN_REQUIRED",
"ERR_NOT_FOUND": "RESOURCE_NOT_FOUND",
"ERR_FORBIDDEN": "AUTH_FORBIDDEN",
"ERR_NOT_AVAILABLE": "SERVICE_UNAVAILABLE",
"ERR_TERMINATION_NOT_ALLOWED": "INSTANCE_TERMINATION_DENIED",
"会话不存在或已失效": "CHAT_TURN_NOT_FOUND",
"无效的会话 ID": "REQUEST_INVALID",
"context manager not initialized": "SERVICE_UNAVAILABLE",
"context manager unavailable": "SERVICE_UNAVAILABLE",
"docgov engine not initialized": "SERVICE_UNAVAILABLE",
"freeform doc service not initialized": "SERVICE_UNAVAILABLE",
"sysadmin service unavailable": "SETTING_STORE_UNAVAILABLE",
"maintenance service unavailable": "SERVICE_UNAVAILABLE",
"storage unavailable": "SERVICE_UNAVAILABLE",
"notification service unavailable": "SERVICE_UNAVAILABLE",
"workspace registry unavailable": "SERVICE_UNAVAILABLE",
"workspace resolver unavailable": "SERVICE_UNAVAILABLE",
"parse engine unavailable": "SERVICE_UNAVAILABLE",
"supplement impact service unavailable": "SERVICE_UNAVAILABLE",
"成员已保存,但受控文档职责同步失败": "INTERNAL",
"无权管理项目成员": "AUTH_FORBIDDEN",
"not a project member": "AUTH_FORBIDDEN",
}
for msg, id := range aliases {
registerLegacy(msg, id)
}
for _, p := range []prefixRule{
{"invalid request body:", "REQUEST_BODY_INVALID"},
{"invalid request json body:", "REQUEST_JSON_INVALID"},
{"invalid multipart form:", "REQUEST_BODY_INVALID"},
{"invalid request body: ", "REQUEST_BODY_INVALID"},
} {
registerPrefix(p.prefix, p.id)
}
}
+165
View File
@@ -0,0 +1,165 @@
package errcode
func init() {
register(Def{ID: "ASSET_NOT_FOUND", Status: 404, Message: "asset not found", Class: ClassFatal, Module: "server"})
register(Def{ID: "ASSET_KEY_AND_FILE_REQUIRED", Status: 400, Message: "asset_key and file required", Class: ClassFatal, Module: "server"})
register(Def{ID: "BATCH_NOT_FOUND", Status: 404, Message: "batch not found", Class: ClassFatal, Module: "server"})
register(Def{ID: "CANNOT_STORE_EMPTY_FILE", Status: 400, Message: "cannot store empty file", Class: ClassFatal, Module: "server"})
register(Def{ID: "CHAT_MESSAGES_IMPORT_ENDPOINT_IS_ONLY_AVAILABLE_IN_DEV_D", Status: 400, Message: "chat messages import endpoint is only available in dev/demo environment", Class: ClassFatal, Module: "server"})
register(Def{ID: "CLIENT_TURN_ID_AND_SESSION_ID_REQUIRED", Status: 400, Message: "client_turn_id and session_id required", Class: ClassFatal, Module: "server"})
register(Def{ID: "DECISION_NONCE_AND_DECISION_DIGEST_REQUIRED", Status: 400, Message: "decision_nonce and decision_digest required", Class: ClassFatal, Module: "server"})
register(Def{ID: "DOC_ID_CANNOT_BE_EMPTY", Status: 400, Message: "doc_id cannot be empty", Class: ClassFatal, Module: "server"})
register(Def{ID: "DOC_ID_REQUIRED", Status: 400, Message: "doc_id required", Class: ClassFatal, Module: "server"})
register(Def{ID: "FILE_REQUIRED", Status: 400, Message: "file required", Class: ClassFatal, Module: "server"})
register(Def{ID: "FILE_TYPE_NOT_SUPPORTED_BY_PARSER", Status: 400, Message: "file type not supported by parser", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_ACTION_MUST_BE_APPROVE_OR_REJECT", Status: 400, Message: "invalid action: must be approve or reject", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_ASSET_ID", Status: 400, Message: "invalid asset id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_ASSET_KEY", Status: 400, Message: "invalid asset key", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_BATCH_ID", Status: 400, Message: "invalid batch id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_COMMENT_ID", Status: 400, Message: "invalid comment id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_EDGE_ID", Status: 400, Message: "invalid edge id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_INSTANCE_ID", Status: 400, Message: "invalid instance id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_MARK_ID", Status: 400, Message: "invalid mark id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_MULTIPART", Status: 400, Message: "invalid multipart", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_PROJECT_ID", Status: 400, Message: "invalid project id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_REPO_PATH", Status: 400, Message: "invalid repo_path", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_REQUEST_ID", Status: 400, Message: "invalid request id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_TASK_KEY", Status: 400, Message: "invalid task key", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_VERSION", Status: 409, Message: "invalid version", Class: ClassResumable, Module: "server"})
register(Def{ID: "INVALID_WORKFLOW_ID", Status: 400, Message: "invalid workflow id", Class: ClassFatal, Module: "server"})
register(Def{ID: "INVALID_WORKSPACE_PATH", Status: 400, Message: "invalid workspace path", Class: ClassFatal, Module: "server"})
register(Def{ID: "KIND_MUST_BE_CONFIRM_OR_CANCEL", Status: 400, Message: "kind must be confirm or cancel", Class: ClassFatal, Module: "server"})
register(Def{ID: "LIST_EXECUTIONS_FAILED", Status: 400, Message: "list executions failed", Class: ClassFatal, Module: "server"})
register(Def{ID: "MESSAGE_CANNOT_BE_EMPTY", Status: 400, Message: "message cannot be empty", Class: ClassFatal, Module: "server"})
register(Def{ID: "MESSAGE_MUST_BE_SENT_VIA_POST_JSON_BODY_NOT_GET_QUERY_T2", Status: 400, Message: "message must be sent via POST JSON body, not GET query (T20-B11)", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_DATE_DAY_KEY_OR_ANCHOR_ID_QUERY_PARAM", Status: 400, Message: "missing date/day_key or anchor_id query param", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_DOC_ID_QUERY_PARAM", Status: 400, Message: "missing doc_id query param", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_OR_EMPTY_QUERY_PARAM_Q", Status: 400, Message: "missing or empty query param 'q'", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_OR_INVALID_PROJECT_ID_QUERY_PARAM", Status: 400, Message: "missing or invalid project_id query param", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_QUERY_PARAM", Status: 400, Message: "missing query param", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_REQUIRED_FIELDS_SESSION_ID_OR_DAY_KEY", Status: 400, Message: "missing required fields 'session_id' or 'day_key'", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_REQUIRED_QUERY_PARAM_SESSION_ID", Status: 400, Message: "missing required query param 'session_id'", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_SESSION_ID", Status: 400, Message: "missing session_id", Class: ClassFatal, Module: "server"})
register(Def{ID: "MISSING_SESSION_ID_QUERY_PARAM", Status: 400, Message: "missing session_id query param", Class: ClassFatal, Module: "server"})
register(Def{ID: "PARSE_IS_IN_PROGRESS_OR_ALREADY_COMPLETE", Status: 409, Message: "parse is in progress or already complete", Class: ClassResumable, Module: "server"})
register(Def{ID: "PROJECT_ROLE_REQUIRED_WHEN_MEMBER_DOES_NOT_EXIST", Status: 400, Message: "project_role required when member does not exist", Class: ClassFatal, Module: "server"})
register(Def{ID: "PROJECT_ROLE_REQUIRED_WITH_DOCUMENT_DUTY", Status: 400, Message: "project_role required with document_duty", Class: ClassFatal, Module: "server"})
register(Def{ID: "PROPOSAL_ID_CANNOT_BE_EMPTY", Status: 400, Message: "proposal_id cannot be empty", Class: ClassFatal, Module: "server"})
register(Def{ID: "PROPOSED_CHANGES_CANNOT_BE_EMPTY", Status: 400, Message: "proposed_changes cannot be empty", Class: ClassFatal, Module: "server"})
register(Def{ID: "READ_FILE_FAILED", Status: 400, Message: "read file failed", Class: ClassFatal, Module: "server"})
register(Def{ID: "REPO_PATH_REQUIRED", Status: 400, Message: "repo_path required", Class: ClassFatal, Module: "server"})
register(Def{ID: "RUN_NOT_FOUND", Status: 404, Message: "run not found", Class: ClassFatal, Module: "server"})
register(Def{ID: "RUN_ID_REQUIRED", Status: 400, Message: "run_id required", Class: ClassFatal, Module: "server"})
register(Def{ID: "SAVE_BINDING_FAILED", Status: 400, Message: "save binding failed", Class: ClassFatal, Module: "server"})
register(Def{ID: "SESSION_OR_PROJECT_CONTEXT_NOT_FOUND", Status: 404, Message: "session or project context not found", Class: ClassFatal, Module: "server"})
register(Def{ID: "STREAMING_UNSUPPORTED", Status: 400, Message: "streaming unsupported", Class: ClassFatal, Module: "server"})
register(Def{ID: "STREAMING_UNSUPPORTED_BY_UNDERLYING_CONNECTION", Status: 400, Message: "streaming unsupported by underlying connection", Class: ClassFatal, Module: "server"})
register(Def{ID: "TURN_NOT_FOUND", Status: 404, Message: "turn not found", Class: ClassFatal, Module: "server"})
register(Def{ID: "UNKNOWN_WORKFLOW", Status: 400, Message: "unknown workflow", Class: ClassFatal, Module: "server"})
register(Def{ID: "UNSUPPORTED_CATEGORY", Status: 400, Message: "unsupported category", Class: ClassFatal, Module: "server"})
register(Def{ID: "UPLOAD_TOO_LARGE", Status: 400, Message: "upload too large", Class: ClassFatal, Module: "server"})
register(Def{ID: "USER_ID_AND_PROJECT_ROLE_REQUIRED", Status: 400, Message: "user_id and project_role required", Class: ClassFatal, Module: "server"})
register(Def{ID: "USER_ID_QUERY_REQUIRED", Status: 400, Message: "user_id query required", Class: ClassFatal, Module: "server"})
register(Def{ID: "USER_ID_REQUIRED", Status: 400, Message: "user_id required", Class: ClassFatal, Module: "server"})
register(Def{ID: "WORKFLOW_CATALOG_UNAVAILABLE", Status: 503, Message: "workflow catalog unavailable", Class: ClassTransient, Module: "server"})
register(Def{ID: "WORKFLOW_KEY_IS_REQUIRED", Status: 400, Message: "workflow_key is required", Class: ClassFatal, Module: "server"})
register(Def{ID: "WORKSPACE_NOT_AVAILABLE_FOR_RUN", Status: 503, Message: "workspace not available for run", Class: ClassTransient, Module: "server"})
register(Def{ID: "MSG_", Status: 400, Message: "仅原批准人或(批准人缺位时)项目创建人可批准再打开", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__2", Status: 400, Message: "只有该阶段文档职责人或创建人可以申请再打开", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__3", Status: 400, Message: "姓名不能为空", Class: ClassFatal, Module: "server"})
register(Def{ID: "TASK_KEY", Status: 400, Message: "客户端不可覆盖 task_key", Class: ClassFatal, Module: "server"})
register(Def{ID: "IAM", Status: 400, Message: "工作流定义服务需要数据库 IAM", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__4", Status: 400, Message: "指定的角色不存在或已停用", Class: ClassFatal, Module: "server"})
register(Def{ID: "ID", Status: 400, Message: "无效的 id", Class: ClassFatal, Module: "server"})
register(Def{ID: "LIMIT", Status: 400, Message: "无效的 limit", Class: ClassFatal, Module: "server"})
register(Def{ID: "ID_2", Status: 400, Message: "无效的用户ID", Class: ClassFatal, Module: "server"})
register(Def{ID: "ID_3", Status: 400, Message: "无效的角色 ID", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__5", Status: 404, Message: "未找到该项目的打开标记", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__6", Status: 400, Message: "申请不属于该项目", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__7", Status: 400, Message: "看不见下游文档,不能裁决", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__8", Status: 400, Message: "至少提供一个可更新字段", Class: ClassFatal, Module: "server"})
register(Def{ID: "MSG__9", Status: 400, Message: "追踪索引不可用", Class: ClassFatal, Module: "server"})
extras := map[string]string{
"asset not found": "ASSET_NOT_FOUND",
"asset_key and file required": "ASSET_KEY_AND_FILE_REQUIRED",
"batch not found": "BATCH_NOT_FOUND",
"cannot store empty file": "CANNOT_STORE_EMPTY_FILE",
"chat messages import endpoint is only available in dev/demo environment": "CHAT_MESSAGES_IMPORT_ENDPOINT_IS_ONLY_AVAILABLE_IN_DEV_D",
"client_turn_id and session_id required": "CLIENT_TURN_ID_AND_SESSION_ID_REQUIRED",
"decision_nonce and decision_digest required": "DECISION_NONCE_AND_DECISION_DIGEST_REQUIRED",
"doc_id cannot be empty": "DOC_ID_CANNOT_BE_EMPTY",
"doc_id required": "DOC_ID_REQUIRED",
"file required": "FILE_REQUIRED",
"file type not supported by parser": "FILE_TYPE_NOT_SUPPORTED_BY_PARSER",
"invalid action: must be approve or reject": "INVALID_ACTION_MUST_BE_APPROVE_OR_REJECT",
"invalid asset id": "INVALID_ASSET_ID",
"invalid asset key": "INVALID_ASSET_KEY",
"invalid batch id": "INVALID_BATCH_ID",
"invalid comment id": "INVALID_COMMENT_ID",
"invalid edge id": "INVALID_EDGE_ID",
"invalid instance id": "INVALID_INSTANCE_ID",
"invalid mark id": "INVALID_MARK_ID",
"invalid multipart": "INVALID_MULTIPART",
"invalid project id": "INVALID_PROJECT_ID",
"invalid repo_path": "INVALID_REPO_PATH",
"invalid request id": "INVALID_REQUEST_ID",
"invalid task key": "INVALID_TASK_KEY",
"invalid version": "INVALID_VERSION",
"invalid workflow id": "INVALID_WORKFLOW_ID",
"invalid workspace path": "INVALID_WORKSPACE_PATH",
"kind must be confirm or cancel": "KIND_MUST_BE_CONFIRM_OR_CANCEL",
"list executions failed": "LIST_EXECUTIONS_FAILED",
"message cannot be empty": "MESSAGE_CANNOT_BE_EMPTY",
"message must be sent via POST JSON body, not GET query (T20-B11)": "MESSAGE_MUST_BE_SENT_VIA_POST_JSON_BODY_NOT_GET_QUERY_T2",
"missing date/day_key or anchor_id query param": "MISSING_DATE_DAY_KEY_OR_ANCHOR_ID_QUERY_PARAM",
"missing doc_id query param": "MISSING_DOC_ID_QUERY_PARAM",
"missing or empty query param 'q'": "MISSING_OR_EMPTY_QUERY_PARAM_Q",
"missing or invalid project_id query param": "MISSING_OR_INVALID_PROJECT_ID_QUERY_PARAM",
"missing query param": "MISSING_QUERY_PARAM",
"missing required fields 'session_id' or 'day_key'": "MISSING_REQUIRED_FIELDS_SESSION_ID_OR_DAY_KEY",
"missing required query param 'session_id'": "MISSING_REQUIRED_QUERY_PARAM_SESSION_ID",
"missing session_id": "MISSING_SESSION_ID",
"missing session_id query param": "MISSING_SESSION_ID_QUERY_PARAM",
"parse is in progress or already complete": "PARSE_IS_IN_PROGRESS_OR_ALREADY_COMPLETE",
"project_role required when member does not exist": "PROJECT_ROLE_REQUIRED_WHEN_MEMBER_DOES_NOT_EXIST",
"project_role required with document_duty": "PROJECT_ROLE_REQUIRED_WITH_DOCUMENT_DUTY",
"proposal_id cannot be empty": "PROPOSAL_ID_CANNOT_BE_EMPTY",
"proposed_changes cannot be empty": "PROPOSED_CHANGES_CANNOT_BE_EMPTY",
"read file failed": "READ_FILE_FAILED",
"repo_path required": "REPO_PATH_REQUIRED",
"run not found": "RUN_NOT_FOUND",
"run_id required": "RUN_ID_REQUIRED",
"save binding failed": "SAVE_BINDING_FAILED",
"session or project context not found": "SESSION_OR_PROJECT_CONTEXT_NOT_FOUND",
"streaming unsupported": "STREAMING_UNSUPPORTED",
"streaming unsupported by underlying connection": "STREAMING_UNSUPPORTED_BY_UNDERLYING_CONNECTION",
"turn not found": "TURN_NOT_FOUND",
"unknown workflow": "UNKNOWN_WORKFLOW",
"unsupported category": "UNSUPPORTED_CATEGORY",
"upload too large": "UPLOAD_TOO_LARGE",
"user_id and project_role required": "USER_ID_AND_PROJECT_ROLE_REQUIRED",
"user_id query required": "USER_ID_QUERY_REQUIRED",
"user_id required": "USER_ID_REQUIRED",
"workflow catalog unavailable": "WORKFLOW_CATALOG_UNAVAILABLE",
"workflow_key is required": "WORKFLOW_KEY_IS_REQUIRED",
"workspace not available for run": "WORKSPACE_NOT_AVAILABLE_FOR_RUN",
"仅原批准人或(批准人缺位时)项目创建人可批准再打开": "MSG_",
"只有该阶段文档职责人或创建人可以申请再打开": "MSG__2",
"姓名不能为空": "MSG__3",
"客户端不可覆盖 task_key": "TASK_KEY",
"工作流定义服务需要数据库 IAM": "IAM",
"指定的角色不存在或已停用": "MSG__4",
"无效的 id": "ID",
"无效的 limit": "LIMIT",
"无效的用户ID": "ID_2",
"无效的角色 ID": "ID_3",
"未找到该项目的打开标记": "MSG__5",
"申请不属于该项目": "MSG__6",
"看不见下游文档,不能裁决": "MSG__7",
"至少提供一个可更新字段": "MSG__8",
"追踪索引不可用": "MSG__9",
}
for msg, id := range extras {
registerLegacy(msg, id)
}
}
+151
View File
@@ -0,0 +1,151 @@
package errorevents
import (
"context"
"fmt"
"strings"
"time"
"chat-agent-system/internal/errcode"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store 是 error_events 的唯一写者。
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
if pool == nil {
return nil
}
return &Store{pool: pool}
}
var active *Store
// Install 把存储注册为进程级错误审计写入器。
func Install(store *Store) {
active = store
if store == nil {
errcode.SetAuditor(nil)
return
}
errcode.SetAuditor(store)
}
// Active 返回当前已安装的存储,供管理台查询。
func Active() *Store { return active }
func (s *Store) Record(ctx context.Context, ev errcode.Event) error {
if s == nil || s.pool == nil {
return fmt.Errorf("errorevents: store unavailable")
}
var actor any
if ev.ActorUserID > 0 {
actor = ev.ActorUserID
}
_, err := s.pool.Exec(ctx, `
INSERT INTO error_events (
code, class, http_status, request_id, session_id, workspace_id,
module, phase_id, path, method, source, upstream, actor_user_id,
retryable, user_message
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15
)`,
ev.Code, emptyClass(ev.Class), zeroNil(ev.HTTPStatus), ev.RequestID, ev.SessionID, ev.WorkspaceID,
ev.Module, ev.PhaseID, ev.Path, ev.Method, emptySource(ev.Source), ev.Upstream, actor,
ev.Retryable, ev.UserMessage,
)
return err
}
// Filter 是管理台查询条件。
type Filter struct {
Code string
RequestID string
Class string
Since time.Time
Until time.Time
Limit int
}
func (s *Store) List(ctx context.Context, f Filter) ([]errcode.Event, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("errorevents: store unavailable")
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
q := `
SELECT id, code, class, COALESCE(http_status,0), COALESCE(request_id,''),
COALESCE(session_id,''), COALESCE(workspace_id,''), COALESCE(module,''),
COALESCE(phase_id,''), COALESCE(path,''), COALESCE(method,''),
COALESCE(source,'http'), COALESCE(upstream,''), COALESCE(actor_user_id,0),
retryable, COALESCE(user_message,''), created_at
FROM error_events WHERE 1=1`
args := make([]any, 0, 6)
add := func(clause string, val any) {
args = append(args, val)
q += fmt.Sprintf(clause, len(args))
}
if v := strings.TrimSpace(f.Code); v != "" {
add(" AND code=$%d", v)
}
if v := strings.TrimSpace(f.RequestID); v != "" {
add(" AND request_id=$%d", v)
}
if v := strings.TrimSpace(f.Class); v != "" {
add(" AND class=$%d", v)
}
if !f.Since.IsZero() {
add(" AND created_at>=$%d", f.Since)
}
if !f.Until.IsZero() {
add(" AND created_at<=$%d", f.Until)
}
args = append(args, limit)
q += fmt.Sprintf(" ORDER BY created_at DESC, id DESC LIMIT $%d", len(args))
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []errcode.Event{}
for rows.Next() {
var ev errcode.Event
if err := rows.Scan(
&ev.ID, &ev.Code, &ev.Class, &ev.HTTPStatus, &ev.RequestID,
&ev.SessionID, &ev.WorkspaceID, &ev.Module, &ev.PhaseID, &ev.Path,
&ev.Method, &ev.Source, &ev.Upstream, &ev.ActorUserID, &ev.Retryable,
&ev.UserMessage, &ev.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func emptyClass(v string) string {
if v == "" {
return errcode.ClassFatal
}
return v
}
func emptySource(v string) string {
if v == "" {
return "http"
}
return v
}
func zeroNil(n int) any {
if n == 0 {
return nil
}
return n
}
+52
View File
@@ -0,0 +1,52 @@
package errorevents
import (
"context"
"testing"
"time"
"chat-agent-system/internal/db"
"chat-agent-system/internal/errcode"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestStoreRoundTrip(t *testing.T) {
dsn := db.SkipIfNoTestDatabaseURL(t)
db.RefuseSharedEnvDSN(t, dsn)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
store := NewStore(pool)
rid := "req_error_events_" + time.Now().UTC().Format("150405.000")
ev := errcode.Event{
Code: "REQUEST_JSON_INVALID",
Class: errcode.ClassFatal,
HTTPStatus: 400,
RequestID: rid,
Module: "server",
Path: "/api/v1/auth/login",
Method: "POST",
Source: "http",
UserMessage: "无效的 JSON 请求体",
}
if err := store.Record(ctx, ev); err != nil {
t.Fatalf("record: %v", err)
}
rows, err := store.List(ctx, Filter{RequestID: rid, Limit: 10})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 1 || rows[0].Code != ev.Code || rows[0].RequestID != rid {
t.Fatalf("rows=%+v", rows)
}
if rows[0].Upstream != "" {
t.Fatalf("upstream=%q", rows[0].Upstream)
}
}
+21 -1
View File
@@ -6,6 +6,8 @@ import (
"strings"
"time"
"chat-agent-system/internal/errcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -289,7 +291,25 @@ func FailLease(ctx context.Context, tx pgx.Tx, l Lease, code string, retryable b
SET finished_at=now(), outcome=$4, error_code=$5
WHERE event_id=$1 AND consumer=$2 AND fencing_token=$3
`, l.EventID, l.Consumer, l.Token, outcome, code)
return err
if err != nil {
return err
}
if outcome == "dead" || outcome == "quarantine" {
id := "EVENTS_DEAD"
if outcome == "quarantine" {
id = "EVENTS_QUARANTINE"
}
errcode.Report(ctx, errcode.Event{
Code: id,
Module: "events",
Source: "async",
RequestID: l.EventID,
PhaseID: l.Consumer,
Upstream: code,
HTTPStatus: 500,
})
}
return nil
}
func HeartbeatLease(ctx context.Context, tx pgx.Tx, l Lease, lease time.Duration) error {
+59 -2
View File
@@ -1,9 +1,11 @@
package httpx
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"strings"
)
@@ -15,6 +17,7 @@ const (
CodeIDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
CodeINTERNAL = "INTERNAL"
HeaderRequestID = "X-Request-ID"
HeaderErrorCode = "X-Error-Code"
)
type Envelope struct {
@@ -101,15 +104,44 @@ func PublicLegacy(code, legacy string) string {
}
func Write(w http.ResponseWriter, status int, code, legacy, requestID string, details map[string]any) {
write(w, nil, status, code, legacy, requestID, "", details)
}
// WriteHTTP 写入错误信封,并记录 method/path。safeMessage 为空时使用按码的固定文案。
func WriteHTTP(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) {
if requestID == "" {
requestID = requestIDFrom(w, r)
}
write(w, r, status, code, legacy, requestID, safeMessage, details)
}
func requestIDFrom(w http.ResponseWriter, r *http.Request) string {
if r != nil {
if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" {
return v
}
}
if w != nil {
if v := strings.TrimSpace(w.Header().Get(HeaderRequestID)); v != "" {
return v
}
}
return RequestID(r)
}
func write(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) {
if code == "" {
code = CodeFromStatus(status)
}
if requestID == "" {
requestID = newRequestID()
requestID = requestIDFrom(w, r)
}
if safeMessage == "" {
safeMessage = SafeMessage(code)
}
env := Envelope{
Code: code,
Message: SafeMessage(code),
Message: safeMessage,
RequestID: requestID,
Error: PublicLegacy(code, legacy),
Details: details,
@@ -118,6 +150,31 @@ func Write(w http.ResponseWriter, status int, code, legacy, requestID string, de
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
w.Header().Set(HeaderRequestID, requestID)
w.Header().Set(HeaderErrorCode, code)
logError(r, status, code, requestID)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(env)
}
func logError(r *http.Request, status int, code, requestID string) {
level := slog.LevelWarn
if status >= 500 {
level = slog.LevelError
}
args := []any{"code", code, "status", status, "request_id", requestID}
if r != nil {
args = append(args, "method", r.Method, "path", stripQuery(r.URL.Path))
}
slog.Log(context.Background(), level, "http_error", args...)
}
func stripQuery(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/"
}
if i := strings.IndexAny(path, "?#"); i >= 0 {
path = path[:i]
}
return path
}
+33
View File
@@ -1,7 +1,9 @@
package httpx
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
@@ -47,6 +49,37 @@ func TestLooksInternalXSS(t *testing.T) {
}
}
func TestWriteLogsCodeWithoutInternalText(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
t.Cleanup(func() { slog.SetDefault(prev) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/login?access_token=leak", nil)
req.Header.Set(HeaderRequestID, "req_log")
rec := httptest.NewRecorder()
WriteHTTP(rec, req, http.StatusInternalServerError, CodeINTERNAL, "pq: password /home/x", "req_log", "请求无法完成", nil)
line := buf.String()
if !strings.Contains(line, `"msg":"http_error"`) && !strings.Contains(line, "http_error") {
t.Fatalf("missing log: %s", line)
}
if !strings.Contains(line, "req_log") || !strings.Contains(line, CodeINTERNAL) {
t.Fatalf("log=%s", line)
}
if !strings.Contains(line, `"/api/v1/login"`) {
t.Fatalf("path=%s", line)
}
low := strings.ToLower(line)
for _, leak := range []string{"password", "pq:", "/home/", "access_token"} {
if strings.Contains(low, leak) {
t.Fatalf("leaked %s in %s", leak, line)
}
}
if rec.Header().Get(HeaderErrorCode) != CodeINTERNAL {
t.Fatalf("header=%s", rec.Header().Get(HeaderErrorCode))
}
}
func TestCodeFromStatus(t *testing.T) {
if CodeFromStatus(http.StatusUnauthorized) != CodeAUTHZ {
t.Fatal("401")
+2
View File
@@ -53,6 +53,8 @@ func (e *DocTaskBusyError) Error() string {
return "document task busy: squad run in progress"
}
func (e *DocTaskBusyError) ErrorCode() string { return "DOC_TASK_BUSY" }
func (e *DocTaskBusyError) Is(target error) bool {
_, ok := target.(*DocTaskBusyError)
return ok
+10 -4
View File
@@ -10,16 +10,17 @@ import (
)
// accessLogMiddleware emits one structured line per HTTP request when enabled.
// It always stamps X-Request-ID so later error writers can reuse it.
// It never logs bodies, Authorization, Cookie, or raw query strings.
func (s *Server) accessLogMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := httpx.RequestID(r)
w.Header().Set(httpx.HeaderRequestID, requestID)
if s == nil || s.cfg == nil || !s.cfg.HTTPAccessLogEnabled {
next.ServeHTTP(w, r)
return
}
started := time.Now()
requestID := httpx.RequestID(r)
w.Header().Set(httpx.HeaderRequestID, requestID)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("http_access",
@@ -28,6 +29,7 @@ func (s *Server) accessLogMiddleware(next http.Handler) http.Handler {
"status", rec.status,
"duration_ms", time.Since(started).Milliseconds(),
"request_id", requestID,
"err_code", rec.Header().Get(httpx.HeaderErrorCode),
)
})
}
@@ -71,12 +73,16 @@ func accessLogPath(path string) string {
}
// accessLogSafeMessage is used by tests to assert the middleware contract.
func accessLogSafeMessage(method, path string, status int, durationMs int64, requestID string) map[string]any {
return map[string]any{
func accessLogSafeMessage(method, path string, status int, durationMs int64, requestID, errCode string) map[string]any {
msg := map[string]any{
"method": method,
"path": accessLogPath(path),
"status": status,
"duration_ms": durationMs,
"request_id": requestID,
}
if errCode != "" {
msg["err_code"] = errCode
}
return msg
}
@@ -16,7 +16,7 @@ func TestAccessLogPath_DropsQueryAndFragment(t *testing.T) {
if got != "/api/v1/chat" {
t.Fatalf("path=%q", got)
}
msg := accessLogSafeMessage("GET", "/x?password=p", 200, 3, "req_1")
msg := accessLogSafeMessage("GET", "/x?password=p", 200, 3, "req_1", "")
blob := strings.ToLower(strings.Join([]string{
msg["method"].(string),
msg["path"].(string),
@@ -0,0 +1,32 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/app"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
)
func TestAdminErrorEvents_ForbiddenForNonAdmin(t *testing.T) {
s := &Server{rt: &app.Runtime{Authz: authz.NewService(nil)}}
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/error-events", nil)
req = req.WithContext(withPrincipal(req.Context(), &auth.UserAccount{ID: 2, RoleID: "role-member"}))
rec := httptest.NewRecorder()
s.handleAdminErrorEvents(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminErrorEvents_Unauthenticated(t *testing.T) {
s := &Server{rt: &app.Runtime{Authz: authz.NewService(nil)}}
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/error-events", nil)
rec := httptest.NewRecorder()
s.handleAdminErrorEvents(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
+4 -4
View File
@@ -18,7 +18,7 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
roles, err := s.authzSvc().ListRoles(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"roles": roles})
@@ -34,7 +34,7 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, authz.ErrRolesNotAvailable) {
status = http.StatusServiceUnavailable
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
writeJSONResponse(w, http.StatusCreated, created)
@@ -72,7 +72,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, authz.ErrRolesNotAvailable):
status = http.StatusServiceUnavailable
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
writeJSONResponse(w, http.StatusOK, updated)
@@ -89,7 +89,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, authz.ErrRolesNotAvailable):
status = http.StatusServiceUnavailable
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
+55 -3
View File
@@ -5,8 +5,11 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/errorevents"
"chat-agent-system/internal/sysadmin"
)
@@ -55,7 +58,7 @@ func (s *Server) handleAdminSystemSettings(w http.ResponseWriter, r *http.Reques
}
settings, err := s.sysadmin().ListSettings(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"settings": settings})
@@ -100,7 +103,7 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re
case errors.Is(err, sysadmin.ErrStoreUnavailable):
writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用")
case err != nil:
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
default:
writeJSONResponse(w, http.StatusOK, setting)
}
@@ -131,8 +134,57 @@ func (s *Server) handleAdminSystemSettingAudits(w http.ResponseWriter, r *http.R
case errors.Is(err, sysadmin.ErrStoreUnavailable):
writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用")
case err != nil:
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
default:
writeJSONResponse(w, http.StatusOK, map[string]any{"audits": audits})
}
}
func (s *Server) handleAdminErrorEvents(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemPermission(w, r, authz.PermSystemAuditRead); !ok {
return
}
store := errorevents.Active()
if store == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "错误审计存储不可用")
return
}
q := r.URL.Query()
limit := 50
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
writeSafeError(w, r, http.StatusBadRequest, "无效的 limit")
return
}
limit = n
}
filter := errorevents.Filter{
Code: q.Get("code"),
RequestID: q.Get("request_id"),
Class: q.Get("class"),
Limit: limit,
}
if raw := strings.TrimSpace(q.Get("since")); raw != "" {
ts, err := time.Parse(time.RFC3339, raw)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, "无效的 since")
return
}
filter.Since = ts
}
if raw := strings.TrimSpace(q.Get("until")); raw != "" {
ts, err := time.Parse(time.RFC3339, raw)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, "无效的 until")
return
}
filter.Until = ts
}
events, err := store.List(r.Context(), filter)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"events": events})
}
+3 -3
View File
@@ -19,7 +19,7 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
assets, err := s.wfDefSvc().ListTemplates(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"templates": assets})
@@ -60,7 +60,7 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, workflowdef.ErrNotAvailable):
status = http.StatusServiceUnavailable
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
writeJSONResponse(w, http.StatusCreated, tv)
@@ -84,7 +84,7 @@ func (s *Server) handleAdminTemplateVersions(w http.ResponseWriter, r *http.Requ
}
versions, err := s.wfDefSvc().ListTemplateVersions(r.Context(), assetKey)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"versions": versions})
+8 -8
View File
@@ -32,7 +32,7 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
defs, err := s.wfDefSvc().ListDefinitions(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": defs})
@@ -48,7 +48,7 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, workflowdef.ErrValidationFailed) {
status = http.StatusUnprocessableEntity
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
writeJSONResponse(w, http.StatusCreated, map[string]any{"definition": def, "version": ver})
@@ -72,12 +72,12 @@ func (s *Server) handleAdminWorkflowByID(w http.ResponseWriter, r *http.Request)
}
def, err := s.wfDefSvc().GetDefinition(r.Context(), id)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
writeDomainError(w, r, http.StatusNotFound, err)
return
}
versions, err := s.wfDefSvc().ListVersions(r.Context(), id)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"definition": def, "versions": versions})
@@ -114,7 +114,7 @@ func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Reque
if errors.Is(err, workflowdef.ErrNotDraft) {
status = http.StatusConflict
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
writeJSONResponse(w, http.StatusOK, updated)
@@ -136,7 +136,7 @@ func (s *Server) handleAdminWorkflowValidate(w http.ResponseWriter, r *http.Requ
version, _ := strconv.Atoi(r.PathValue("version"))
res, err := s.wfDefSvc().ValidateVersion(r.Context(), workflowID, version)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
writeDomainError(w, r, http.StatusNotFound, err)
return
}
writeJSONResponse(w, http.StatusOK, res)
@@ -159,7 +159,7 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque
if errors.Is(err, workflowdef.ErrValidationFailed) {
status = http.StatusUnprocessableEntity
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
if refresher, ok := s.pwSvc().Catalog().(interface {
@@ -182,7 +182,7 @@ func (s *Server) handleAdminWorkflowRetire(w http.ResponseWriter, r *http.Reques
version, _ := strconv.Atoi(r.PathValue("version"))
ver, err := s.wfDefSvc().RetireVersion(r.Context(), workflowID, version)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if refresher, ok := s.pwSvc().Catalog().(interface {
+4 -4
View File
@@ -41,7 +41,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
writeSafeError(w, r, http.StatusRequestEntityTooLarge, "upload too large")
return
}
writeSafeError(w, r, http.StatusBadRequest, "invalid multipart form: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
file, header, err := r.FormFile("file")
@@ -68,7 +68,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
declaredMIME := header.Header.Get("Content-Type")
mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data)
if verr != nil {
writeSafeError(w, r, http.StatusUnsupportedMediaType, "upload rejected: "+verr.Error())
writeDomainError(w, r, http.StatusUnsupportedMediaType, verr)
return
}
@@ -79,7 +79,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
}
ref, err := cas.StoreFile(r.Context(), projectID, userID, filepath.Base(filename), "material", data, mimeType)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "store asset failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -176,7 +176,7 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request)
}
refs, err := cas.ListProjectRefs(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
+12 -12
View File
@@ -130,7 +130,7 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
status = http.StatusInternalServerError
slog.Error("auth_register_failed", "request_id", httpx.RequestID(r), "err", err)
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
@@ -166,7 +166,7 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request
}
if err := s.authSvc().ChangePassword(r.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -229,7 +229,7 @@ func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
sessions, listErr := s.authSvc().ListUserSessions(r.Context(), user.ID, token)
if listErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, listErr.Error())
writeDomainError(w, r, http.StatusInternalServerError, listErr)
return
}
if sessions == nil {
@@ -272,7 +272,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
users, err := s.authSvc().ListUsers(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -289,7 +289,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if roleID := strings.TrimSpace(req.RoleID); roleID != "" && s.authzSvc() != nil && s.authzSvc().Enabled() {
roles, listErr := s.authzSvc().ListRoles(r.Context())
if listErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, listErr.Error())
writeDomainError(w, r, http.StatusInternalServerError, listErr)
return
}
found := false
@@ -310,7 +310,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if authIsInfrastructureError(err) {
status = http.StatusInternalServerError
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -344,7 +344,7 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
updated, err := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -376,14 +376,14 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
if req.RoleID != "" {
if _, roleErr := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position); roleErr != nil {
writeSafeError(w, r, http.StatusBadRequest, roleErr.Error())
writeDomainError(w, r, http.StatusBadRequest, roleErr)
return
}
}
updated, err := s.authSvc().UpdateUserStatus(r.Context(), targetID, req.Status)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -434,7 +434,7 @@ func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request)
}
cfg, err := s.authSvc().GetConfig(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -450,7 +450,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
cfg, err := s.authSvc().GetConfig(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -465,7 +465,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.authSvc().UpdateConfig(r.Context(), req); err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
+2 -2
View File
@@ -253,8 +253,8 @@ func TestAuthHandlers_RegisterDuplicateUsername(t *testing.T) {
if !bytes.Contains([]byte(errText), []byte("已存在")) {
t.Fatalf("expected error to mention 已存在, got %#v", env)
}
if env["code"] != "VALIDATION" {
t.Fatalf("expected VALIDATION, got %#v", env["code"])
if env["code"] != "AUTH_USERNAME_EXISTS" {
t.Fatalf("expected AUTH_USERNAME_EXISTS, got %#v", env["code"])
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ func writeAuthzError(w http.ResponseWriter, err error) {
"message": "请先登录",
})
default:
writeSafeError(w, nil, http.StatusInternalServerError, err.Error())
writeDomainError(w, nil, http.StatusInternalServerError, err)
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Reques
var req GenerateDigestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request json body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
return
}
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
+1 -1
View File
@@ -76,7 +76,7 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
var req ctxmgr.MarkChatReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if req.SessionID == "" {
+1 -1
View File
@@ -188,7 +188,7 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request
var items []importMessageItem
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
+1 -1
View File
@@ -32,6 +32,6 @@ func (s *Server) writeChatLogError(w http.ResponseWriter, err error) {
case errors.Is(err, chatflow.ErrClosed):
writeSafeError(w, nil, http.StatusServiceUnavailable, "context manager not initialized")
default:
writeSafeError(w, nil, http.StatusInternalServerError, err.Error())
writeDomainError(w, nil, http.StatusInternalServerError, err)
}
}
@@ -216,7 +216,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
if kind == "controlled" && s.gov() != nil {
// ActorIsMember=true:上游 enforceDocGovWorkflowAction(Edit) 已按成员级放行。
if err := s.gov().AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor, true); err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
holderID := fmt.Sprintf("%d", user.ID)
+129 -4
View File
@@ -4,8 +4,18 @@ import (
"errors"
"net/http"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/errcode"
"chat-agent-system/internal/freedoc"
"chat-agent-system/internal/httpx"
"chat-agent-system/internal/maintenance"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/swboot"
"chat-agent-system/internal/sysadmin"
"chat-agent-system/internal/taskrun"
)
// domainHTTPStatus is the single adapter that maps known domain sentinel /
@@ -53,21 +63,31 @@ func domainHTTPStatus(err error) (status int, ok bool) {
}
}
func writeTaskError(w http.ResponseWriter, err error, payload any) {
func writeTaskError(w http.ResponseWriter, r *http.Request, err error, payload any) {
status := http.StatusInternalServerError
if mapped, ok := domainHTTPStatus(err); ok {
status = mapped
}
if payload != nil {
def := errcode.FromStatus(status)
if d, found := errcode.FromError(err); found {
def = d
}
noteCoded(w, r, status, def, err)
writeJSONResponse(w, status, payload)
return
}
writeSafeError(w, nil, status, err.Error())
writeDomainError(w, r, status, err)
}
func writeDocGovError(w http.ResponseWriter, err error) {
func writeDocGovError(w http.ResponseWriter, r *http.Request, err error) {
var busy *docgov.DocumentBusyError
if errors.As(err, &busy) {
def, ok := errcode.Lookup("DOC_BUSY")
if !ok {
def = errcode.FromStatus(http.StatusConflict)
}
noteCoded(w, r, http.StatusConflict, def, err)
writeJSONResponse(w, http.StatusConflict, map[string]any{
"error": "document_busy",
"detail": busy,
@@ -78,5 +98,110 @@ func writeDocGovError(w http.ResponseWriter, err error) {
if mapped, ok := domainHTTPStatus(err); ok {
status = mapped
}
writeSafeError(w, nil, status, err.Error())
writeDomainError(w, r, status, err)
}
func noteCoded(w http.ResponseWriter, r *http.Request, status int, def errcode.Def, err error) {
rid := incomingRequestID(w, r)
if rid == "" {
rid = httpx.RequestID(r)
}
if w != nil {
w.Header().Set(httpx.HeaderRequestID, rid)
w.Header().Set(httpx.HeaderErrorCode, def.ID)
}
legacy := ""
if err != nil {
legacy = err.Error()
}
reportHTTPError(r, status, def, rid, legacy)
}
func init() {
bind := func(err error, id string) { errcode.Bind(err, id) }
bind(docgov.ErrDocumentNotFound, "DOCUMENT_NOT_FOUND")
bind(docgov.ErrDocumentLocked, "DOCUMENT_LOCKED")
bind(docgov.ErrDocumentBusy, "DOC_BUSY")
bind(docgov.ErrLeaseNotHeld, "LEASE_NOT_HELD")
bind(docgov.ErrLeaseExpired, "LEASE_EXPIRED")
bind(docgov.ErrStaleFencing, "LEASE_STALE")
bind(docgov.ErrForceEditNotAllowed, "FORCE_EDIT_NOT_ALLOWED")
bind(docgov.ErrProposalNotFound, "PROPOSAL_NOT_FOUND")
bind(docgov.ErrUnauthorizedActor, "UNAUTHORIZED_ACTOR")
bind(docgov.ErrOnlyAuthorCanEdit, "AUTHOR_ONLY_EDIT")
bind(docgov.ErrReviewerCannotEdit, "REVIEWER_CANNOT_EDIT")
bind(docgov.ErrApproverCannotEdit, "APPROVER_CANNOT_EDIT")
bind(docgov.ErrInvalidStateTransition, "INVALID_STATE_TRANSITION")
bind(docgov.ErrRejectReasonRequired, "REJECT_REASON_REQUIRED")
bind(docgov.ErrProposalAlreadyDecided, "PROPOSAL_ALREADY_DECIDED")
bind(docgov.ErrChangeProposalPending, "CHANGE_PROPOSAL_PENDING")
bind(docgov.ErrContentProjectionMissing, "DOCUMENT_CONTENT_PROJECTION_MISSING")
bind(docgov.ErrContentProjectionEmpty, "DOCUMENT_CONTENT_PROJECTION_EMPTY")
bind(docgov.ErrContentIntegrityMismatch, "DOCUMENT_CONTENT_INTEGRITY_MISMATCH")
bind(freedoc.ErrNotFound, "FREEDOC_NOT_FOUND")
bind(freedoc.ErrForbidden, "FREEDOC_FORBIDDEN")
bind(freedoc.ErrConflict, "FREEDOC_CONFLICT")
bind(freedoc.ErrInvalidParam, "FREEDOC_INVALID_PARAM")
bind(freedoc.ErrMessageNotExportable, "FREEDOC_NOT_EXPORTABLE")
bind(auth.ErrUsernameExists, "AUTH_USERNAME_EXISTS")
bind(sysadmin.ErrUnknownSetting, "SETTING_UNKNOWN")
bind(sysadmin.ErrSettingReadOnly, "SETTING_READ_ONLY")
bind(sysadmin.ErrInvalidSettingValue, "SETTING_INVALID_VALUE")
bind(sysadmin.ErrStoreUnavailable, "SETTING_STORE_UNAVAILABLE")
bind(sysadmin.ErrExpectedRevisionRequired, "SETTING_REVISION_REQUIRED")
bind(sysadmin.ErrSettingRevisionConflict, "SETTING_REVISION_CONFLICT")
bind(sysadmin.ErrSettingNotFound, "SETTING_NOT_FOUND")
bind(projectworkflow.ErrUnauthenticated, "WORKFLOW_UNAUTHENTICATED")
bind(projectworkflow.ErrForbiddenNotCreator, "WORKFLOW_FORBIDDEN_NOT_CREATOR")
bind(projectworkflow.ErrProjectNotFound, "PROJECT_NOT_FOUND")
bind(projectworkflow.ErrWorkflowNotFound, "WORKFLOW_NOT_FOUND")
bind(projectworkflow.ErrConcurrentConflict, "WORKFLOW_CONCURRENT_CONFLICT")
bind(projectworkflow.ErrPrerequisiteNotMet, "WORKFLOW_PREREQUISITE")
bind(projectworkflow.ErrInvalidInput, "WORKFLOW_INVALID_INPUT")
bind(projectworkflow.ErrDuplicateProjectName, "PROJECT_NAME_DUPLICATE")
bind(projectworkflow.ErrLegacySwitchBlocked, "WORKFLOW_LEGACY_SWITCH_BLOCKED")
bind(taskrun.ErrRunNotResumable, "TASKRUN_NOT_RESUMABLE")
bind(taskrun.ErrRunAlreadyActive, "TASKRUN_ALREADY_ACTIVE")
bind(taskrun.ErrTerminalConflict, "TASKRUN_TERMINAL_CONFLICT")
bind(taskrun.ErrCleanupBlocked, "TASKRUN_CLEANUP_BLOCKED")
bind(taskrun.ErrUnauthorizedBackend, "TASKRUN_UNAUTHORIZED_BACKEND")
bind(taskrun.ErrSensitivePayload, "TASKRUN_SENSITIVE_PAYLOAD")
bind(taskrun.ErrStoreNotAvailable, "TASKRUN_STORE_UNAVAILABLE")
bind(taskrun.ErrIdempotencyConflict, "TASKRUN_IDEMPOTENCY_CONFLICT")
bind(ctxmgr.ErrSessionOwnerMismatch, "SESSION_OWNER_MISMATCH")
bind(ctxmgr.ErrChatTurnNotFound, "CHAT_TURN_NOT_FOUND")
bind(ctxmgr.ErrIdempotencyKeyReused, "IDEMPOTENCY_KEY_REUSED")
bind(maintenance.ErrNotFound, "MAINTENANCE_NOT_FOUND")
bind(maintenance.ErrConflictOpen, "MAINTENANCE_CONFLICT_OPEN")
bind(maintenance.ErrInvalidWindow, "MAINTENANCE_INVALID_WINDOW")
bind(maintenance.ErrBadStatus, "MAINTENANCE_BAD_STATUS")
bind(swboot.ErrMergeRequiresSeparateAuthorization, "SWBOOT_MERGE_FORBIDDEN")
bind(swboot.ErrRepoPathNotAllowlisted, "SWBOOT_REPO_NOT_ALLOWLISTED")
bind(swboot.ErrControlledPatchDisabled, "SWBOOT_PATCH_DISABLED")
bind(swboot.ErrPatchExecutionUnimplemented, "SWBOOT_PATCH_UNIMPLEMENTED")
bind(projectinstance.ErrNotAvailable, "INSTANCE_NOT_AVAILABLE")
bind(projectinstance.ErrForbiddenNotCreator, "INSTANCE_FORBIDDEN_NOT_CREATOR")
bind(projectinstance.ErrPlanFrozen, "INSTANCE_PLAN_FROZEN")
bind(projectinstance.ErrWorkflowSwitchFrozen, "INSTANCE_SWITCH_FROZEN")
bind(projectinstance.ErrWorkflowAlreadyActive, "INSTANCE_ALREADY_ACTIVE")
bind(projectinstance.ErrPlanChangedReconfirm, "INSTANCE_PLAN_RECONFIRM")
bind(projectinstance.ErrValidationFailed, "INSTANCE_VALIDATION_FAILED")
bind(projectinstance.ErrTokenInvalid, "INSTANCE_ACTIVATION_INVALID")
bind(projectinstance.ErrConcurrentConflict, "INSTANCE_CONFLICT")
bind(projectinstance.ErrPlanRevisionConflict, "INSTANCE_PLAN_CONFLICT")
bind(projectinstance.ErrInstanceNotFound, "INSTANCE_NOT_FOUND")
bind(projectinstance.ErrStageGateNotReady, "INSTANCE_STAGE_GATE")
bind(projectinstance.ErrStageNotActive, "INSTANCE_STAGE_NOT_ACTIVE")
bind(projectinstance.ErrStageNotCompleted, "INSTANCE_STAGE_NOT_COMPLETED")
bind(projectinstance.ErrRecheckAckRequired, "INSTANCE_RECHECK_REQUIRED")
bind(projectinstance.ErrInvalidStage, "INSTANCE_INVALID_STAGE")
bind(projectinstance.ErrInstanceRevisionConflict, "INSTANCE_REVISION_CONFLICT")
bind(projectinstance.ErrTerminationNotAllowed, "INSTANCE_TERMINATION_DENIED")
bind(projectinstance.ErrAuthorOnlyPolicyDisabled, "INSTANCE_AUTHOR_ONLY_DISABLED")
bind(projectinstance.ErrAuthorOnlyPolicyUnavailable, "INSTANCE_AUTHOR_ONLY_UNAVAILABLE")
bind(projectinstance.ErrAuthorOnlyPolicyChanged, "INSTANCE_AUTHOR_ONLY_CHANGED")
bind(projectinstance.ErrForbiddenDutyReassign, "INSTANCE_DUTY_FORBIDDEN")
bind(projectinstance.ErrDocTaskNotFound, "INSTANCE_DOC_TASK_NOT_FOUND")
bind(projectinstance.ErrLegacySwitchBlocked, "INSTANCE_LEGACY_SWITCH_BLOCKED")
bind(projectinstance.ErrTaskScopeNotResolved, "TASK_SCOPE_UNRESOLVED")
}
+6 -6
View File
@@ -45,25 +45,25 @@ func TestDomainHTTPStatus_MapsKnownSentinels(t *testing.T) {
func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) {
rec := httptest.NewRecorder()
writeDocGovError(rec, docgov.ErrProposalNotFound)
writeDocGovError(rec, nil, docgov.ErrProposalNotFound)
if rec.Code != http.StatusNotFound {
t.Fatalf("status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeDocGovError(rec, docgov.ErrUnauthorizedActor)
writeDocGovError(rec, nil, docgov.ErrUnauthorizedActor)
if rec.Code != http.StatusForbidden {
t.Fatalf("status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeDocGovError(rec, docgov.ErrOnlyAuthorCanEdit)
writeDocGovError(rec, nil, docgov.ErrOnlyAuthorCanEdit)
if rec.Code != http.StatusForbidden {
t.Fatalf("OnlyAuthorCanEdit status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeDocGovError(rec, &docgov.DocumentBusyError{DocID: "d", Reason: "held"})
writeDocGovError(rec, nil, &docgov.DocumentBusyError{DocID: "d", Reason: "held"})
if rec.Code != http.StatusConflict {
t.Fatalf("status %d", rec.Code)
}
@@ -78,13 +78,13 @@ func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) {
func TestWriteTaskError_DefaultsTo500(t *testing.T) {
rec := httptest.NewRecorder()
writeTaskError(rec, errors.New("boom"), nil)
writeTaskError(rec, nil, errors.New("boom"), nil)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeTaskError(rec, docgov.ErrDocumentBusy, map[string]string{"ok": "no"})
writeTaskError(rec, nil, docgov.ErrDocumentBusy, map[string]string{"ok": "no"})
if rec.Code != http.StatusConflict {
t.Fatalf("status %d", rec.Code)
}
+4 -4
View File
@@ -61,7 +61,7 @@ func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request)
Offset: offset,
})
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list chat sessions failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -101,7 +101,7 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request)
}
can, err := s.authzSvc().CanManageProjectMembers(r.Context(), req.ProjectID, user.ID, user.RoleID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
if !can {
@@ -123,7 +123,7 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request)
})
return
}
writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -217,7 +217,7 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) {
MinScore: minScore,
})
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "search similar cases failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
+6 -6
View File
@@ -52,7 +52,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
storedMessage, attachmentOnly, reject := prepareChatTurn(req.Message, req.Attachments)
@@ -85,7 +85,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
})
return
}
writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
s.hydrateVisibleHistory(r.Context(), sess, user.ID)
@@ -115,7 +115,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
return sendErr
})
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "process chat message failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -130,7 +130,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
var req TaskRunRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
req.UserID = user.ID
@@ -213,7 +213,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
if runRecord != nil {
_ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error())
}
writeTaskError(w, err, stageRes)
writeTaskError(w, r, err, stageRes)
return
}
if runRecord != nil && stageRes != nil && stageRes.PrimaryArtifact != nil {
@@ -263,7 +263,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
if runRecord != nil {
_ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error())
}
writeTaskError(w, err, taskRes)
writeTaskError(w, r, err, taskRes)
return
}
+4 -4
View File
@@ -56,7 +56,7 @@ func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
uCtx, err := s.ctxMgr().GetUserContext(r.Context(), projectID, userID, sessionID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "get user context failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -71,7 +71,7 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque
}
var req UpdateUserPreferenceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if req.UserID <= 0 {
@@ -99,7 +99,7 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque
}
if err := s.ctxMgr().UpdateUserPreference(r.Context(), pref); err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "save user preference failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -138,7 +138,7 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request)
pCtx, err := s.ctxMgr().GetProjectContext(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "get project context failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
+22 -22
View File
@@ -42,7 +42,7 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request)
doc, err := s.gov().GetDocument(r.Context(), docID)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error())
writeDomainError(w, r, http.StatusNotFound, err)
return
}
@@ -53,7 +53,7 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
var req DocGovClaimRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if req.DocID == "" {
@@ -73,7 +73,7 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
doc, err := s.gov().RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -89,7 +89,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request
var req DocGovSubmitReviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -103,7 +103,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request
doc, err := s.gov().SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -119,7 +119,7 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque
var req DocGovDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -149,7 +149,7 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque
}
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, "review decision failed: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -165,7 +165,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
var req DocGovDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -207,14 +207,14 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
"request_id", httpx.RequestID(r), "doc_id", req.DocID,
"action", action, "user_id", user.ID, "error", err.Error())
if errors.Is(err, uow.ErrPermissionDenied) {
writeSafeError(w, r, http.StatusForbidden, "approver decision failed: "+err.Error())
writeDomainError(w, r, http.StatusForbidden, err)
return
}
if errors.Is(err, uow.ErrBusy) {
writeSafeError(w, r, http.StatusConflict, "approver decision failed: "+err.Error())
writeDomainError(w, r, http.StatusConflict, err)
return
}
writeSafeError(w, r, http.StatusBadRequest, "approver decision failed: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -230,7 +230,7 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.
var req ProposeModificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if strings.TrimSpace(req.DocID) == "" {
@@ -268,7 +268,7 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.
writeSafeError(w, r, status, "document not found")
return
}
writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -284,7 +284,7 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
var req DecideProposalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if strings.TrimSpace(req.ProposalID) == "" {
@@ -304,13 +304,13 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
case http.StatusNotFound:
writeSafeError(w, r, status, "proposal not found")
case http.StatusForbidden:
writeSafeError(w, r, status, "decide proposal failed: "+err.Error())
writeDomainError(w, r, status, err)
default:
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
}
return
}
writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -346,7 +346,7 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request
comments, err := s.gov().ListComments(r.Context(), docID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -358,7 +358,7 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
var req AddCommentRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
@@ -382,7 +382,7 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content,
)
if err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
@@ -424,7 +424,7 @@ func (s *Server) handleDocGovResolveComment(w http.ResponseWriter, r *http.Reque
role := s.resolveDocGovRole(r.Context(), docID, user)
comment, err := s.gov().ResolveComment(r.Context(), commentID, user.ID, role, resolve)
if err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
writeJSONResponse(w, http.StatusOK, comment)
@@ -449,7 +449,7 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
logs, err := s.gov().ListAuditLogs(r.Context(), docID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
+9 -9
View File
@@ -21,7 +21,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request)
}
var req SaveContentRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
@@ -44,7 +44,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request)
FencingToken: req.FencingToken,
})
if err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
writeJSONResponse(w, http.StatusOK, res)
@@ -56,7 +56,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
}
var req LeaseRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
@@ -68,7 +68,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
return
}
if err := s.gov().CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, true); err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
req.HolderID = fmt.Sprintf("%d", user.ID)
@@ -86,7 +86,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
TTL: ttl,
})
if err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
@@ -99,7 +99,7 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque
}
var req LeaseRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
@@ -119,7 +119,7 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque
ttl := time.Duration(req.TTLSeconds) * time.Second
doc, err := s.gov().HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl)
if err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
@@ -132,7 +132,7 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request
}
var req LeaseRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
@@ -151,7 +151,7 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request
}
doc, err := s.gov().ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID)
if err != nil {
writeDocGovError(w, err)
writeDocGovError(w, r, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
+92 -1
View File
@@ -1,9 +1,12 @@
package server
import (
"context"
"encoding/json"
"net/http"
"strings"
"chat-agent-system/internal/errcode"
"chat-agent-system/internal/httpx"
)
@@ -14,5 +17,93 @@ func writeJSONResponse(w http.ResponseWriter, status int, data any) {
}
func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy string) {
httpx.Write(w, status, httpx.CodeFromStatus(status), legacy, httpx.RequestID(r), nil)
emitError(w, r, status, errcode.FromLegacy(status, legacy), legacy)
}
func writeDomainError(w http.ResponseWriter, r *http.Request, status int, err error) {
if err == nil {
writeSafeError(w, r, status, "")
return
}
if st, ok := domainHTTPStatus(err); ok {
def := errcode.FromStatus(st)
if d, found := errcode.FromError(err); found {
def = d
}
emitError(w, r, st, def, err.Error())
return
}
if def, found := errcode.FromError(err); found {
// 处理器传入的状态是既有 HTTP 契约。注册表状态只在调用方未给出时使用。
st := status
if st == 0 {
st = def.Status
}
if st == 0 {
st = http.StatusInternalServerError
}
emitError(w, r, st, def, err.Error())
return
}
emitError(w, r, status, errcode.FromLegacy(status, err.Error()), err.Error())
}
func emitError(w http.ResponseWriter, r *http.Request, status int, def errcode.Def, legacy string) {
if def.ID == "" {
def = errcode.FromStatus(status)
}
details := map[string]any{}
if def.Guide != "" {
details["guide"] = def.Guide
}
if def.Module != "" {
details["module"] = def.Module
}
rid := incomingRequestID(w, r)
httpx.WriteHTTP(w, r, status, def.ID, legacy, rid, def.Message, details)
if rid == "" && w != nil {
rid = w.Header().Get(httpx.HeaderRequestID)
}
reportHTTPError(r, status, def, rid, legacy)
}
func incomingRequestID(w http.ResponseWriter, r *http.Request) string {
if r != nil {
if v := strings.TrimSpace(r.Header.Get(httpx.HeaderRequestID)); v != "" {
return v
}
}
if w != nil {
if v := strings.TrimSpace(w.Header().Get(httpx.HeaderRequestID)); v != "" {
return v
}
}
return ""
}
func reportHTTPError(r *http.Request, status int, def errcode.Def, requestID, legacy string) {
ev := errcode.Event{
Code: def.ID,
Class: def.Class,
HTTPStatus: status,
RequestID: requestID,
Module: def.Module,
Source: "http",
Retryable: def.Retryable,
UserMessage: def.Message,
Upstream: legacy,
}
ctx := context.Background()
if r != nil {
ev.Method = r.Method
ev.Path = r.URL.Path
if sid := strings.TrimSpace(r.URL.Query().Get("session_id")); sid != "" {
ev.SessionID = sid
}
if user := principalFromContext(r.Context()); user != nil {
ev.ActorUserID = user.ID
}
ctx = r.Context()
}
errcode.Report(ctx, ev)
}
+5 -5
View File
@@ -26,7 +26,7 @@ func (s *Server) handleMaintenanceStatus(w http.ResponseWriter, r *http.Request)
}
view, err := s.maintenance().CurrentStatus(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, view)
@@ -52,7 +52,7 @@ func (s *Server) handleAdminMaintenanceList(w http.ResponseWriter, r *http.Reque
}
list, err := s.maintenance().ListRecent(r.Context(), 20)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"windows": list})
@@ -86,11 +86,11 @@ func (s *Server) handleAdminMaintenanceSchedule(w http.ResponseWriter, r *http.R
})
switch {
case errors.Is(err, maintenance.ErrInvalidWindow):
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
case errors.Is(err, maintenance.ErrConflictOpen):
writeSafeError(w, r, http.StatusConflict, "已有未结束的维护窗口")
case err != nil:
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
default:
writeJSONResponse(w, http.StatusOK, wnd)
}
@@ -114,7 +114,7 @@ func (s *Server) handleAdminMaintenanceCancel(w http.ResponseWriter, r *http.Req
case errors.Is(err, maintenance.ErrBadStatus):
writeSafeError(w, r, http.StatusConflict, "当前状态不可取消")
case err != nil:
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
default:
writeJSONResponse(w, http.StatusOK, wnd)
}
+3 -3
View File
@@ -55,7 +55,7 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) {
Timezone: patch.Timezone,
})
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeJSONResponse(w, http.StatusOK, saved)
@@ -74,7 +74,7 @@ func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
prefs, err := s.authSvc().GetUserPreferences(r.Context(), user.ID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, prefs)
@@ -86,7 +86,7 @@ func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) {
}
prefs, err := s.authSvc().SaveUserPreferences(r.Context(), user.ID, body)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, prefs)
+2 -2
View File
@@ -33,7 +33,7 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R
items, err := s.notifySvc().ListProjectNotifications(r.Context(), user.ID, projectID, sinceID, limit)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
@@ -76,7 +76,7 @@ func (s *Server) handleListUserNotifications(w http.ResponseWriter, r *http.Requ
items, err := s.listAllScopeNotifications(r.Context(), user.ID, sinceID, limit)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
@@ -4,6 +4,8 @@ import (
"context"
"log/slog"
"time"
"chat-agent-system/internal/errcode"
)
const defaultReminderPollInterval = 15 * time.Minute
@@ -38,6 +40,12 @@ func (s *Server) runStageAdvanceReminders(ctx context.Context) {
n, err := s.notifySvc().ProcessStageAdvanceReminders(ctx)
if err != nil {
slog.Warn("stage advance reminder scan failed", "error", err)
errcode.Report(ctx, errcode.Event{
Code: "REMINDER_SCAN_FAILED",
Module: "notification",
Source: "async",
Upstream: err.Error(),
})
return
}
if n > 0 {
+1 -1
View File
@@ -94,7 +94,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
}
docs, err := s.gov().ListWorkflowDocumentsByProject(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
+10 -10
View File
@@ -21,7 +21,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
return
}
if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, permErr)
return
} else if !ok {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
@@ -40,7 +40,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
proj, err := s.pwSvc().CreateProject(r.Context(), user, req)
if err != nil {
if errors.Is(err, projectworkflow.ErrInvalidInput) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if errors.Is(err, projectworkflow.ErrDuplicateProjectName) {
@@ -51,10 +51,10 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
return
}
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -93,7 +93,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
list, err = s.pwSvc().ListProjects(r.Context(), user.ID)
}
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
if list == nil {
@@ -122,7 +122,7 @@ func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在或无权限访问"}`)
return
}
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -143,7 +143,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque
wfs, err := s.pwSvc().ListProjectWorkflows(r.Context(), id)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
if wfs == nil {
@@ -201,7 +201,7 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request)
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`)
return
}
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -274,7 +274,7 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re
wfKey := r.PathValue("workflow_key")
runs, err := s.pwSvc().ListWorkflowRuns(r.Context(), id, wfKey, 20)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
if runs == nil {
@@ -307,7 +307,7 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request)
}
lineages, err := s.pwSvc().GetLineageForProjectDownstream(r.Context(), projectID, docID, 0)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
if len(lineages) == 0 {
+7 -7
View File
@@ -75,7 +75,7 @@ func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request
}
members, err := s.authorizer().ListProjectMembers(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
if members == nil {
@@ -87,7 +87,7 @@ func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request
func (s *Server) requireProjectMemberManage(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool {
can, err := s.authzSvc().CanManageProjectMembers(r.Context(), projectID, user.ID, user.RoleID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return false
}
if !can {
@@ -113,7 +113,7 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request,
ProjectRole: req.ProjectRole,
DocumentDuty: req.DocumentDuty,
}); err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if err := s.healProjectDuties(r.Context(), projectID); err != nil {
@@ -154,7 +154,7 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques
if s.authorizer() != nil {
rec, err := s.authorizer().GetProjectMember(r.Context(), projectID, req.UserID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
existing = rec
@@ -173,14 +173,14 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques
ProjectRole: role,
DocumentDuty: req.DocumentDuty,
}); err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
wrote = true
}
if req.Status != "" {
if err := s.authorizer().SetProjectMemberStatus(r.Context(), projectID, req.UserID, req.Status); err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
wrote = true
@@ -205,7 +205,7 @@ func (s *Server) handleRemoveProjectMember(w http.ResponseWriter, r *http.Reques
return
}
if err := s.authorizer().SetProjectMemberStatus(r.Context(), projectID, targetID, authz.StatusExited); err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
if err := s.healProjectDuties(r.Context(), projectID); err != nil {
+3 -3
View File
@@ -194,7 +194,7 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
case errors.Is(err, projectinstance.ErrProjectNotFound):
writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`)
case errors.Is(err, projectinstance.ErrInvalidPlan):
writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`)
writeDomainError(w, nil, http.StatusBadRequest, err)
case errors.Is(err, projectinstance.ErrInstanceNotFound):
writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_INSTANCE_NOT_FOUND","message":"工作流实例不存在"}`)
case errors.Is(err, projectinstance.ErrStageGateNotReady):
@@ -232,7 +232,7 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
})
return
}
writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, nil, http.StatusInternalServerError, err)
}
}
@@ -247,7 +247,7 @@ func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Reques
}
live, err := s.instSvc().HasLiveInstance(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return true
}
if live {
+1 -1
View File
@@ -42,7 +42,7 @@ func (s *Server) handleReconcileWorkspace(w http.ResponseWriter, r *http.Request
report, err := squad.ReconcileWorkspaceStatus(workDir)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeJSONResponse(w, http.StatusOK, report)
+1
View File
@@ -346,6 +346,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/admin/system/features", s.handleAdminSystemFeatures)
mux.HandleFunc("GET /api/v1/admin/system/settings", s.handleAdminSystemSettings)
mux.HandleFunc("GET /api/v1/admin/system/settings/{key}/audits", s.handleAdminSystemSettingAudits)
mux.HandleFunc("GET /api/v1/admin/system/error-events", s.handleAdminErrorEvents)
mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey)
mux.HandleFunc("GET /api/v1/maintenance/status", s.handleMaintenanceStatus)
+1 -1
View File
@@ -38,7 +38,7 @@ func (s *Server) handleProjectCreationPreview(w http.ResponseWriter, r *http.Req
return
}
if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, permErr)
return
} else if !ok {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
+6 -6
View File
@@ -146,7 +146,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
var req StreamChatRequest
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
} else {
@@ -213,7 +213,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
})
return
}
writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
s.hydrateVisibleHistory(r.Context(), sess, user.ID)
@@ -380,7 +380,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
includeDetails := false
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
} else {
@@ -447,7 +447,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
var reused *taskrun.RunRecord
if idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID); idemKey != "" && s.taskRuns() != nil {
if existing, findErr := s.taskRuns().FindByIdempotencyKey(r.Context(), idemKey); findErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, findErr.Error())
writeDomainError(w, r, http.StatusInternalServerError, findErr)
return
} else if existing != nil {
if existing.UserID != req.UserID || existing.SessionID != req.SessionID ||
@@ -469,7 +469,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
var err error
created, err = s.taskRuns().CreateAndStart(r.Context(), runID, record, taskReq, stageReq, req.WorkDir)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "start task run failed: "+err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
}
@@ -478,7 +478,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
ch, replay, err := s.taskRuns().Subscribe(created.RunID, 0)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
defer s.taskRuns().Unsubscribe(created.RunID, ch)
+2 -2
View File
@@ -85,7 +85,7 @@ func (s *Server) handleSwbootExecutions(w http.ResponseWriter, r *http.Request)
case http.MethodPost:
exec, err := s.swbootExec()
if err != nil {
writeSafeError(w, r, http.StatusServiceUnavailable, err.Error())
writeDomainError(w, r, http.StatusServiceUnavailable, err)
return
}
var req swbootExecutionRequest
@@ -160,7 +160,7 @@ func (s *Server) handleSwbootBinding(w http.ResponseWriter, r *http.Request) {
}
exec, err := s.swbootExec()
if err != nil {
writeSafeError(w, r, http.StatusServiceUnavailable, err.Error())
writeDomainError(w, r, http.StatusServiceUnavailable, err)
return
}
var req swbootBindingRequest
+13 -13
View File
@@ -53,7 +53,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
}
var req createTaskRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
req.UserID = user.ID
@@ -67,7 +67,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
}
}
if err := bizdev.ValidateTaskRunIdentity(req.SessionID, req.ProjectID); err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if req.TaskKey == "" && req.Stage == "" {
@@ -87,7 +87,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID)
if idemKey != "" && s.taskRuns() != nil {
if existing, findErr := s.taskRuns().FindByIdempotencyKey(r.Context(), idemKey); findErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, findErr.Error())
writeDomainError(w, r, http.StatusInternalServerError, findErr)
return
} else if existing != nil {
if !taskRunMatchesIdempotentOwner(existing, req) {
@@ -135,7 +135,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
return
}
if busy, preflightErr := s.preflightTaskDocumentBusy(r.Context(), req); preflightErr != nil {
writeSafeError(w, r, http.StatusBadRequest, preflightErr.Error())
writeDomainError(w, r, http.StatusBadRequest, preflightErr)
return
} else if busy != nil {
s.recordBusyPreflightMessages(r.Context(), ctxMgr, req, taskScope, busy)
@@ -166,7 +166,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
}
claimed, created, claimErr := s.taskRuns().ClaimRun(r.Context(), record)
if claimErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error())
writeDomainError(w, r, http.StatusInternalServerError, claimErr)
return
}
if !created {
@@ -223,7 +223,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
stageReq := s.buildBizdevStageRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, claimed.RunID)
createdRun, err := s.taskRuns().StartClaimedRun(r.Context(), claimed, taskReq, stageReq, resumeDir)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
@@ -258,7 +258,7 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque
}
claimed, created, claimErr := s.taskRuns().ClaimRun(r.Context(), record)
if claimErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error())
writeDomainError(w, r, http.StatusInternalServerError, claimErr)
return
}
if !created {
@@ -498,7 +498,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
// (same order as handleChatStream / CreateAndStart in sse.go).
ch, replay, err := s.taskRuns().Subscribe(runID, afterSeq)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
writeDomainError(w, r, http.StatusNotFound, err)
return
}
defer s.taskRuns().Unsubscribe(runID, ch)
@@ -584,7 +584,7 @@ func (s *Server) handleCancelTaskRun(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.taskRuns().Cancel(runID); err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
writeDomainError(w, r, http.StatusNotFound, err)
return
}
if rec, getErr := s.taskRuns().GetRun(runID); getErr == nil && rec.WorkspaceID != "" {
@@ -624,7 +624,7 @@ func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) {
}
rec, err := s.taskRuns().GetRun(runID)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
writeDomainError(w, r, http.StatusNotFound, err)
return
}
writeJSONResponse(w, http.StatusOK, s.buildTaskRunResponse(rec))
@@ -646,7 +646,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
var req resumeTaskRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if !req.Confirm {
@@ -659,7 +659,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
rec, err := s.taskRuns().GetRun(runID)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
writeDomainError(w, r, http.StatusNotFound, err)
return
}
if rec.Status != "interrupted" {
@@ -711,7 +711,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
})
return
}
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
+6 -6
View File
@@ -20,7 +20,7 @@ func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) {
}
items, err := s.rt.Trace.ListItems(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
visible, hidden := traceimpact.RedactItems(items, func(docID string) bool {
@@ -28,17 +28,17 @@ func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) {
})
index, err := s.rt.Trace.ListIndex(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "")
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
candidates, err := s.rt.Trace.ListCandidateEdges(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
@@ -83,7 +83,7 @@ func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Reques
}
rep, err := s.rt.Trace.Analyze(traceimpact.WithActor(r.Context(), user.ID), projectID, body.DocRef, body.Others)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, rep)
@@ -194,7 +194,7 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R
}
marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "")
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
found := false
+2 -2
View File
@@ -21,7 +21,7 @@ func (s *Server) handleUserDirectory(w http.ResponseWriter, r *http.Request) {
}
users, err := s.authSvc().ListUserDirectory(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"users": users})
@@ -67,7 +67,7 @@ func (s *Server) handleAdminUpdateProfile(w http.ResponseWriter, r *http.Request
EmployeeID: body.EmployeeID,
}, actor.ID)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeJSONResponse(w, http.StatusOK, updated)
+1 -1
View File
@@ -22,7 +22,7 @@ func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) {
}
defs, err := s.wfDefSvc().Store().ListProjectSelectableDefinitions(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
productDomain := strings.TrimSpace(r.URL.Query().Get("product_domain"))
@@ -72,7 +72,7 @@ func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.
case errors.Is(err, projectinstance.ErrTerminationNotAllowed):
writeSafeError(w, r, http.StatusConflict, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`)
default:
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
writeDomainError(w, r, http.StatusInternalServerError, err)
}
return
}
+4 -4
View File
@@ -42,12 +42,12 @@ func (s *Server) serveAdminWorkspaces(w http.ResponseWriter, r *http.Request) {
return
}
if err := reg.Discover(); err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
list, err := reg.List()
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
var bytes int64
@@ -79,7 +79,7 @@ func (s *Server) serveAdminWorkspaceCleanup(w http.ResponseWriter, r *http.Reque
}
if body.Frozen != nil {
if err := reg.SetRetention(id, *body.Frozen, body.Receipts); err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
}
@@ -92,7 +92,7 @@ func (s *Server) serveAdminWorkspaceCleanup(w http.ResponseWriter, r *http.Reque
if err == workspace.ErrWorkDirNotFound {
status = http.StatusNotFound
}
writeSafeError(w, r, status, err.Error())
writeDomainError(w, r, status, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"deleted": id})
+18
View File
@@ -1,6 +1,7 @@
package stream
import (
"chat-agent-system/internal/errcode"
"errors"
"io"
"net"
@@ -28,6 +29,23 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string)
info.Upstream = truncateRunes(raw, 80)
}
if def, ok := errcode.FromError(err); ok {
info.Code = def.ID
info.Class = ErrorClass(def.Class)
if info.Class == "" {
info.Class = ErrorClassFatal
}
info.UserMessage = def.Message
info.Guide = def.Guide
if len(def.Actions) == 0 {
info.Actions = []string{}
} else {
info.Actions = append([]string(nil), def.Actions...)
}
info.Message = info.UserMessage
return info
}
switch {
case strings.Contains(msg, "编辑中") || strings.Contains(msg, "禁止并行修改") || strings.Contains(msg, "edit lease"):
info.Code = "DOC_BUSY"
+18
View File
@@ -160,3 +160,21 @@ func TestClassifyError_BodyTooLargeIsFatalNotResumable(t *testing.T) {
t.Fatalf("class=%s", info.Class)
}
}
type codedBusy struct{}
func (codedBusy) Error() string { return "plain failure" }
func (codedBusy) ErrorCode() string { return "DOC_BUSY" }
func TestClassifyError_PrefersRegisteredCode(t *testing.T) {
info := ClassifyError(codedBusy{}, "phase", "req_typed", "sess", "")
if info.Code != "DOC_BUSY" {
t.Fatalf("code=%s", info.Code)
}
if info.Guide == "" || info.UserMessage == "" {
t.Fatalf("%+v", info)
}
if info.RequestID != "req_typed" {
t.Fatalf("request=%s", info.RequestID)
}
}
+1
View File
@@ -120,6 +120,7 @@ type ErrorInfo struct {
Message string `json:"message,omitempty"`
Code string `json:"code"`
Class ErrorClass `json:"class"`
Guide string `json:"guide,omitempty"`
PhaseID string `json:"phase_id,omitempty"`
Upstream string `json:"upstream,omitempty"`
RequestID string `json:"request_id,omitempty"`
+32
View File
@@ -10,6 +10,7 @@ import (
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/errcode"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/stream"
)
@@ -495,6 +496,37 @@ func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, r
"workspace_id", workspaceID,
"error", errMsg,
)
if status == "failed" || status == "cancelled" || status == "finalizing_failed" {
code := "TASKRUN_BACKGROUND"
class := errcode.ClassFatal
requestID := ""
phaseID := ""
userMessage := errMsg
if errorInfo != nil {
if errorInfo.Code != "" {
code = errorInfo.Code
}
if errorInfo.Class != "" {
class = string(errorInfo.Class)
}
requestID = errorInfo.RequestID
phaseID = errorInfo.PhaseID
if errorInfo.UserMessage != "" {
userMessage = errorInfo.UserMessage
}
}
errcode.Report(context.Background(), errcode.Event{
Code: code,
Class: class,
RequestID: requestID,
PhaseID: phaseID,
Module: "taskrun",
Source: "async",
WorkspaceID: workspaceID,
Upstream: errMsg,
UserMessage: userMessage,
})
}
if m.store != nil && m.store.Available() {
_ = m.store.ReleaseLease(context.Background(), runID, m.workerID)
}
+2
View File
@@ -44,6 +44,7 @@ export type StreamEvent = {
session_id?: string;
workspace_id?: string;
actions?: string[];
guide?: string;
};
turn?: {
client_turn_id?: string;
@@ -280,6 +281,7 @@ export function mapErrorInfo(ev: StreamEvent): ChatErrorInfo {
requestId: ei.request_id,
sessionId: ei.session_id,
workspaceId: ei.workspace_id,
guide: ei.guide,
actions,
};
}
+26
View File
@@ -5,6 +5,7 @@ import type {
SystemKbInfo,
SystemOverview,
SystemSetting,
ErrorEventRecord,
} from '../types/systemAdmin';
const BASE = '/api/v1/admin/system';
@@ -63,3 +64,28 @@ export async function updateSystemSetting(
const data = (await resp.json()) as { setting?: SystemSetting } & SystemSetting;
return data.setting ?? data;
}
export interface ErrorEventQuery {
code?: string;
requestId?: string;
className?: string;
since?: string;
until?: string;
limit?: number;
}
export async function fetchErrorEvents(query: ErrorEventQuery = {}): Promise<ErrorEventRecord[]> {
const params = new URLSearchParams();
if (query.code) params.set('code', query.code);
if (query.requestId) params.set('request_id', query.requestId);
if (query.className) params.set('class', query.className);
if (query.since) params.set('since', query.since);
if (query.until) params.set('until', query.until);
if (query.limit) params.set('limit', String(query.limit));
const qs = params.toString();
const data = await getJson<{ events?: ErrorEventRecord[] }>(
`${BASE}/error-events${qs ? `?${qs}` : ''}`,
'获取错误审计失败',
);
return data.events ?? [];
}
@@ -10,6 +10,7 @@ import {
ShieldAlert,
ShieldX,
Sliders,
ScrollText,
ToggleLeft,
Users,
Wrench,
@@ -19,6 +20,7 @@ import { UserProfile } from '../../../types/docgov';
import { RoleDefinition } from '../../../types/rbac';
import { DocsSection } from './sections/DocsSection';
import { EnvSettingsSection } from './sections/EnvSettingsSection';
import { ErrorEventsSection } from './sections/ErrorEventsSection';
import { FeaturesSection } from './sections/FeaturesSection';
import { KbSection } from './sections/KbSection';
import { MaintenanceSection } from './sections/MaintenanceSection';
@@ -43,6 +45,7 @@ type SectionKey =
| 'kb'
| 'features'
| 'settings'
| 'errors'
| 'env'
| 'maintenance'
| 'workflows'
@@ -55,6 +58,7 @@ const NAV_ITEMS: Array<{ key: SectionKey; label: string; icon: React.ReactNode }
{ key: 'kb', label: '知识库连接', icon: <Database size={16} /> },
{ key: 'features', label: '特性功能开关', icon: <ToggleLeft size={16} /> },
{ key: 'settings', label: '系统设置白名单', icon: <Sliders size={16} /> },
{ key: 'errors', label: '错误审计', icon: <ScrollText size={16} /> },
{ key: 'env', label: '模型与密钥', icon: <KeyRound size={16} /> },
{ key: 'maintenance', label: '维护计划', icon: <Wrench size={16} /> },
{ key: 'workflows', label: '工作流定义', icon: <GitBranch size={16} /> },
@@ -103,6 +107,8 @@ export const AdminConsoleView: React.FC<AdminConsoleViewProps> = ({
return <FeaturesSection />;
case 'settings':
return <SettingsSection />;
case 'errors':
return <ErrorEventsSection />;
case 'env':
return <EnvSettingsSection />;
case 'maintenance':
@@ -115,6 +121,10 @@ export const AdminConsoleView: React.FC<AdminConsoleViewProps> = ({
return <UsersSection currentUser={currentUser} onRefreshUsers={onUsersChanged} />;
case 'docs':
return <DocsSection />;
default: {
const _exhaustive: never = activeSection;
return _exhaustive;
}
}
};
@@ -0,0 +1,133 @@
import React, { useCallback, useState } from 'react';
import { fetchErrorEvents, type ErrorEventQuery } from '../../../../api/systemAdmin';
import type { ErrorEventRecord } from '../../../../types/systemAdmin';
import {
SectionError,
SectionHeader,
SectionLoading,
sectionCardStyle,
sectionTableStyle,
sectionTdStyle,
sectionThStyle,
} from './sectionCommon';
export const ErrorEventsSection: React.FC = () => {
const [query, setQuery] = useState<ErrorEventQuery>({ limit: 50 });
const [draft, setDraft] = useState({ code: '', requestId: '', className: '' });
const [rows, setRows] = useState<ErrorEventRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState('');
const load = useCallback(async (next: ErrorEventQuery) => {
setLoading(true);
setError(null);
try {
setRows(await fetchErrorEvents(next));
} catch (err) {
setError(err instanceof Error ? err.message : '获取错误审计失败');
} finally {
setLoading(false);
}
}, []);
const onSearch = () => {
const next: ErrorEventQuery = {
limit: 50,
code: draft.code.trim() || undefined,
requestId: draft.requestId.trim() || undefined,
className: draft.className.trim() || undefined,
};
setQuery(next);
void load(next);
};
const copy = async (value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(value);
} catch {
setCopied('');
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<SectionHeader
title="错误审计"
desc="按错误码或 request id 检索 HTTP 与异步失败。排查步骤见错误码目录,不在这里展示密钥或 SQL。"
/>
<div style={{ ...sectionCardStyle, display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<input
aria-label="错误码"
placeholder="错误码,如 DOC_BUSY"
value={draft.code}
onChange={(e) => setDraft((d) => ({ ...d, code: e.target.value }))}
style={{ padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--bg-app)', color: 'var(--text-strong)' }}
/>
<input
aria-label="request id"
placeholder="request id"
value={draft.requestId}
onChange={(e) => setDraft((d) => ({ ...d, requestId: e.target.value }))}
style={{ padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--bg-app)', color: 'var(--text-strong)', minWidth: '220px' }}
/>
<input
aria-label="错误类别"
placeholder="class:transient / fatal"
value={draft.className}
onChange={(e) => setDraft((d) => ({ ...d, className: e.target.value }))}
style={{ padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--bg-app)', color: 'var(--text-strong)' }}
/>
<button type="button" onClick={onSearch} style={{ padding: '8px 14px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--brand-500, #4f6bff)', color: '#fff', cursor: 'pointer' }}>
查询
</button>
</div>
{loading && <SectionLoading text="正在查询错误审计…" />}
{error && <SectionError message={error} onRetry={() => void load(query)} />}
{!loading && !error && (
<div style={{ ...sectionCardStyle, padding: 0, overflow: 'auto' }}>
<table style={sectionTableStyle}>
<thead>
<tr>
<th style={sectionThStyle}>时间</th>
<th style={sectionThStyle}>错误码</th>
<th style={sectionThStyle}>类别</th>
<th style={sectionThStyle}>来源</th>
<th style={sectionThStyle}>路径</th>
<th style={sectionThStyle}>request id</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td style={sectionTdStyle} colSpan={6}>
还没有查询结果。输入错误码或 request id 后查询。
</td>
</tr>
)}
{rows.map((row) => (
<tr key={row.id}>
<td style={sectionTdStyle}>{row.createdAt ? new Date(row.createdAt).toLocaleString() : '-'}</td>
<td style={{ ...sectionTdStyle, fontFamily: 'var(--font-mono)' }}>{row.code}</td>
<td style={sectionTdStyle}>{row.class || '-'}</td>
<td style={sectionTdStyle}>{row.source || row.module || '-'}</td>
<td style={sectionTdStyle}>{row.method ? `${row.method} ` : ''}{row.path || '-'}</td>
<td style={sectionTdStyle}>
{row.requestId ? (
<button type="button" onClick={() => void copy(row.requestId || '')} style={{ fontFamily: 'var(--font-mono)', background: 'transparent', border: 'none', color: 'var(--brand-400, #6b84ff)', cursor: 'pointer', padding: 0 }}>
{copied === row.requestId ? '已复制' : row.requestId}
</button>
) : (
'-'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
@@ -382,6 +382,13 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
{buildDiagLine(message.errorInfo)}
</div>
)}
<details style={{ fontSize: '11.5px', color: 'var(--text-muted)' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>查看排查指引</summary>
<div style={{ marginTop: '6px', lineHeight: 1.5 }}>
{message.errorInfo.guide ||
`错误码 ${message.errorInfo.code || 'UNKNOWN'}。请把错误码和 request id 交给管理员,在系统管理台「错误审计」中检索。`}
</div>
</details>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '2px' }}>
<button
+20
View File
@@ -69,3 +69,23 @@ export interface SystemSetting {
applyStatus?: string;
applyError?: string;
}
export interface ErrorEventRecord {
id: number;
code: string;
class?: string;
httpStatus?: number;
requestId?: string;
sessionId?: string;
workspaceId?: string;
module?: string;
phaseId?: string;
path?: string;
method?: string;
source?: string;
upstream?: string;
actorUserId?: number;
retryable?: boolean;
userMessage?: string;
createdAt?: string;
}
+1
View File
@@ -160,6 +160,7 @@ export interface ChatErrorInfo {
requestId?: string;
sessionId?: string;
workspaceId?: string;
guide?: string;
actions: Array<'retry' | 'continue' | 'restart'>;
}