# DingTalk 取件走 dws(DingTalk Workspace CLI) 默认**优先使用 dws** 取钉钉知识库字节,老路径(本地镜像树 + 浏览器 a-token 实况导出)作为回退保留。 ## 1. 开关 | 环境变量 | 取值 | 含义 | |---|---|---| | `ARTIFACT_DINGTALK_FETCHER` | `auto`(默认) | 先 dws;**仅当** dws 返回可重试错误(429/5xx/超时/连接层/dws 二进制缺失)时回落到老路径 | | | `dws` | 只用 dws,失败即失败(灰度验证/压测用) | | | `legacy` | 完全绕过 dws,走原逻辑(回退开关) | | `DWS_BIN` | 默认 `/usr/local/bin/dws` | dws 可执行文件 | | `DWS_CONFIG_DIR` | 容器内 `/data/dws` | dws 配置目录(profiles/identity) | | `DWS_HOME` | 容器内 `/data/home` | **dws 子进程专用 HOME**:文件型 keychain 必须位于 `$HOME/.local/share/dws-cli`(`XDG_DATA_HOME` 不被识别),只给 dws 子进程换 HOME,不动 worker 全局 HOME | | `DWS_DISABLE_KEYCHAIN` | `1` | 无系统 keychain 的环境用 file-DEK 模式 | ## 2. 路由 按**已解析文件名后缀**路由(与老路径判定一致;manifest 无 `declared_type`,缺失时依次退回 `declared_type` → `dws drive +inspect`): | 后缀 | 取件方式 | 产物 | |---|---|---| | `adoc` | `dws doc export --export-format docx` | docx | | `axls` | `dws sheet list` + `sheet range read` → `scripts/axls_to_xlsx.py` 落盘 | xlsx | | 其余可入库类型 | `dws drive +download` | 原后缀 | | `dlink` / `hlink` | 直接 `missing`(链接节点 fileSize=0) | — | | `amind` / `appt` | `unsupported_format`(CLI 无内容导出入口,与老路径等价) | — | **平台侧判定一律保留**:`pick_suffix`、`SLIDES`(PPT 必须 PDF 双胞胎)、magic 校验、大小上限、`sha256`、spool 原子写、`exported_doc_is_blank()` 空壳检查。dws 只负责“取字节”。 ## 3. 错误映射 `scripts/dws_error_map.py` 把 dws 的错误体(严格版 `ok/error` 包裹、普通子命令 `error` 对象、本地错误、TLS/网络文本)统一映射为平台语义 `{ok, code, retryable, missing}`。要点: - 只有明确瞬时信号(429/5xx/超时/连接层失败/**服务端导出任务失败**/**dws 二进制缺失**)为 `retryable=true`; - **服务端导出任务失败 `export_failed` 判为可重试**:并发实测同一节点 8 路时偶发失败、1/2/4 路与串行必成(详见 §7),若判终态会把本可成功的文档永久失败; - 未分类错误 `code=unknown` 且**不重试**——避免被无限重试掩盖真因; - `workspace node not found` → `missing=true`(走 missing 分支而非 failed); - TLS 缺 CA(`x509: certificate signed by unknown authority`)→ `tls_ca`,终态(重试无意义,需补 CA)。 覆盖样本与断言见 `scripts/test_dws_error_map.py`(18 个断言,样本为实测捕获的真实错误体)。 ## 7. 并发实测与上限(真实 worker 容器内) 在**已部署的 worker 容器**里用平台自己的 `artifact_fetch.py` 实测(产物写容器 /tmp,不动 spool;每级用不同节点,避免服务端导出去重缓存失真): | 场景 | 并发 | 结果 | 墙钟 | 单件均/p95 | 取件器 | |---|---|---|---|---|---| | 常规体积在线文档(容器 <200MB) | 8 | **8/8** | 3.6s | 3.5s / 3.5s | dws | | 常规体积在线文档 | 12 | **12/12** | 3.5s | 3.4s / 3.5s | dws | | 巨型在线文档(容器 307MB~1.33GB) | 1 | **1/1** | 4.3s | 4.3s | dws | | 巨型在线文档 | 2 | **2/2** | 4.6s | 4.5s / 4.4s | dws | | 巨型在线文档 | 4 | **3/3** | 4.7s | 4.3s / 4.6s | dws | | 混合 8 路(6 常规 + 2 巨型) | 8 | 6/8 | 3.8s | — | 2× `export_failed`(可重试) | 结论与要求: - **吞吐随并发线性增长**(12/3.5s ≈ 3.4 件/秒),**全程无 429**;墙钟基本恒定 → 瓶颈是服务端导出任务而非本地。 - **常规体积:≥12 路安全**;**巨型在线文档:4 路已验证安全**;唯一见过的失败是 8 路混合时巨型文档的服务端导出偶发失败 → 已判 `export_failed=retryable`,并建议 worker 侧对在线文档并发**不超过 4**。 - **目录大小只在"普通文件"时等于真实字节数**:`adoc/axls` 等在线文档的目录大小是"容器大小"(实测有 322MB/590MB/**1.40GB** 的 adoc,导出 docx 仅 15MB/48MB/49.5MB)。因此大小预检**只对普通文件生效**;曾对在线文档生效导致 3 个可导出文档被误判 `over_limit`(已修,见 §8 回归)。 - 定点复测(修复后、同一生产容器):3 个巨型 adoc 全部 `ok` 导出 docx;无权限的 axls 从"无结果的 `no_result`"变为明确的 `forbidden`(终态、不重试)。 ## 8. 压测暴露并已修复的回归 | 回归 | 症状 | 修复 | 测试 | |---|---|---|---| | 在线文档被大小预检误拒 | 3 个 adoc(容器 322MB~1.4GB)`over_limit` 永久失败 | 预检只对普通文件生效(在线文档以导出件实际大小为准) | `test_online_document_with_huge_catalog_size_still_exports` | | 转换器错误绕过平台契约 | axls 权限失败时进程无结果退出 → 平台只看到 `artifact_fetch_no_output` | `axls_to_xlsx` 抛 `DwsCallError`,取件器映射成 `forbidden`(CLI 侧 exit 2) | `test_axls_converter_failure_maps_to_platform_error` | | 服务端导出偶发失败被判终态 | 8 路混合下的 `export_failed` 永久失败 | `export_failed` 改判 `retryable=true` | `dws_error_map` 新增断言 | ## 4. 镜像与部署 - 镜像:`alpine:3.22` + `gcompat libc6-compat ca-certificates`(官方 dws 是动态链接 glibc 的二进制,靠 gcompat 在 musl 上运行;已验证端到端字节一致);dws **版本与 sha256 固定在 Dockerfile 的 `DWS_VERSION`/`DWS_SHA256`**,**禁用 `dws upgrade`**。 - 凭据:**与宿主共享同一份 state**(refresh token 是轮换制,两个副本会互相顶掉): - `~/.dws` → `/data/dws`(`DWS_CONFIG_DIR`) - `~/.local/share/dws-cli` → `/data/home/.local/share/dws-cli`(`DWS_HOME`) - 容器必须 `--user <宿主 uid>:`(worker 已是 `1000:1000`),否则 state 被写成 root 所有。 ## 5. 续期 Runbook(refresh token 30 天) ```bash # 到期前 7 天(阈值告警)在目标机执行,本人完成浏览器授权 dws auth login --device # 验证必须真实调用,而不是只看 authenticated 字段 dws wiki +space-list --limit 1 -f json # 期望 ok:true docker exec dws auth status --readonly -f json ``` 出现 `code=credentials` 时**立即停批**(不要重试风暴),重新授权后 `--retry-failed` 续跑。健康面板建议展示 `refresh_expires_at`:<7 天黄、<3 天红。 ## 6. 回退演练(上线前必做) ```bash docker compose exec worker env ARTIFACT_DINGTALK_FETCHER=legacy ... # 或改 compose 环境变量后重建 worker # 取同一批节点 → 断言 spool_ref/sha256 契约不变、无新增 code=* → 再切回 auto ``` 保留 `dingtalk-login` sidecar 与 63GB 镜像树至少 2 周(镜像树可降级为只读热备)。 ## 7. 验证证据(2026-10-09) | 项 | 结果 | |---|---| | 影子比对(真实 worker 容器 vs 宿主 dws) | adoc 24/24 等价(正文相似度 1.0、media 逐项相等、11/24 字节完全相同);普通文件 5/5 `sha256` 一致;axls 归一化后值覆盖 100%/100% | | 容器端到端(本改动 + 真容器约束 `--read-only --tmpfs /tmp --user 1000:1000`) | adoc/docx 305,078B(sha `77b393c3…`,与平台基准一致)、axls/xlsx 3,330B、pdf 27,140B → **3/3 成功**;结果 JSON 字段与改动前完全一致 | | 单元测试 | `scripts/artifact_fetch_test.py` 14 项、`scripts/artifact_fetch_blank_doc_test.py` 5 项、`scripts/test_dws_error_map.py` 17 断言 —— 全绿 | | 大表能力 | 平台老路径失败的 220MB/15 表/3,658 行 axls,dws + 转换器 18s 产出 128KB 有效 xlsx |