Compare commits

...
Author SHA1 Message Date
瑞达 e675d353bb docs(todo): refine multi-skill routing from train run 2 2026-08-21 14:50:51 +08:00
瑞达 78ed8f27b1 docs(todo): optimize multi-skill routing from train run 1 2026-08-21 14:50:51 +08:00
github-actions[bot] cd90d1c322 Merge pull request #1075 from Justper/oa_attachment_upload_dws
Oa attachment upload dws
2026-08-21 14:46:26 +08:00
昭逸 78433198fb Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 14:23:27 +08:00
昭逸 4863152a4a Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 14:20:33 +08:00
昭逸 2057fec3b0 fix(oa): normalize spaceId/fileSize to match ResultSpec integer declaration to #666
- validateOAAttachmentCommitResult 改为返回归一化后的 result map
- string 型 spaceId 通过 ParseInt 转 int64,json.Number 同理,非法字符串报错
- fileSize 的 json.Number 同样归一化为 int64
- 新增归一化行为测试 + 输出契约测试,覆盖率 100% to #666
2026-08-21 14:19:53 +08:00
github-actions[bot] 8b56e9bc9e Merge pull request #1073 from maoqxxmm/codex/sheet-revision-changeset
feat(sheet): add revision and changeset inspection
2026-08-21 05:56:10 +00:00
毛球 87e141f2de Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 13:38:56 +08:00
github-actions[bot] 9b521f0392 chore: update beta formula for v1.0.60-beta.1 [skip ci] 2026-08-21 05:17:50 +00:00
YanChangzhi 4dcd528bc1 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 13:11:34 +08:00
赤川 0bbb3a9d32 Merge pull request #1087 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.1
chore: prepare v1.0.60-beta.1 changelog
2026-08-21 12:48:22 +08:00
chichuan 0ebd840ba9 chore: prepare v1.0.60-beta.1 changelog 2026-08-21 12:35:38 +08:00
github-actions[bot] 9d356cd664 Merge pull request #1076 from hlzjsong/refresh_org_slot_fix
refresh org slot not only identity
2026-08-21 04:20:48 +00:00
YanChangzhi b00f43ee06 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 12:14:19 +08:00
赤川 23167ef974 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:46:45 +08:00
毛球 8b003aef16 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 11:43:45 +08:00
github-actions[bot] 11934eed05 Merge pull request #1081 from DingTalk-Real-AI/codex/fix-report-requiredness-governance
feat(policy): govern optional-to-required flag migrations
2026-08-21 11:40:11 +08:00
YanChangzhi 23e1085a37 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 11:16:29 +08:00
赤川 a352615e77 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:15:06 +08:00
xiatian d210da501a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 11:14:03 +08:00
赤川 6288199a93 Merge branch 'main' into codex/fix-report-requiredness-governance 2026-08-21 11:05:33 +08:00
赤川 5b34ed1a7e Merge pull request #1084 from DingTalk-Real-AI/codex/docs-dws-cli-open
docs: 公告 DWS CLI 全面开放
2026-08-21 11:02:36 +08:00
chichuan 3d9a469347 docs: announce DWS CLI availability 2026-08-21 11:01:08 +08:00
xiatian 7640ba7614 fix(sheet): validate changeset audit integrity 2026-08-21 10:59:14 +08:00
昭逸 4886bdb3f5 Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 10:41:06 +08:00
昭逸 4aef07ccd3 fix(oa): validate commit response required fields before reporting success to #666
- 新增 validateOAAttachmentCommitResult 校验 spaceId/fileName/fileSize/fileId 必需字段
- commit 步不再使用通用 callOAAttachmentResultCtx,改为专用校验后才存储成功结果
- 补充 malformed commit 响应回归测试,覆盖率 100%
2026-08-21 10:40:46 +08:00
github-actions[bot] e0c49377d6 Merge pull request #1074 from DingTalk-Real-AI/codex/investigate-calendar-todo-comment-regressions
fix: harden calendar todo and comment shortcuts
2026-08-21 10:30:04 +08:00
昭逸 dc37dd2c34 fix(oa): remove DDAttachment from unsupported table and use testseam.Swap to #666
- 从 oa-form-components.md (mono/multi) 的"API 不支持的控件"表中移除 DDAttachment,避免 Agent 误判为不支持
- computeFileMD5 测试注入改为 testseam.Swap,符合仓库包变量注入约定
2026-08-21 09:56:48 +08:00
hlzjsong 02aad9020a Merge branch 'main' into refresh_org_slot_fix 2026-08-21 09:26:39 +08:00
昭逸 d59d1091dc Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 08:51:13 +08:00
昭逸 60d6bfeec4 Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 08:50:28 +08:00
昭逸 bf89acb3d2 fix ci fail to #666 2026-08-21 08:50:13 +08:00
Dennis 35d6f47bd6 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-21 08:17:52 +08:00
xiatian d24b71614a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:23:17 +08:00
github-actions[bot] 765b961f4d Merge pull request #1071 from DingTalk-Real-AI/codex/fix-stable-active-fragments
fix(release): consume post-beta fragments in stable seals
2026-08-21 01:06:18 +08:00
xiatian 9ab4bd10a5 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:05:47 +08:00
Dennis 1c8b83ec2f Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 23:58:13 +08:00
Dennis bb6f470df5 test: address shortcut regression review 2026-08-20 23:56:08 +08:00
赤川 01af71a5ae Merge branch 'main' into oa_attachment_upload_dws 2026-08-20 23:47:06 +08:00
赤川 47e3c2b3c5 Merge branch 'main' into refresh_org_slot_fix 2026-08-20 23:19:33 +08:00
xiatian e8ecff586a fix(sheet): classify malformed revision responses 2026-08-20 23:06:56 +08:00
Dennis 23940e4752 revert: keep coverage shard output compact 2026-08-20 22:24:17 +08:00
Dennis bbaf033618 ci: stream app coverage progress 2026-08-20 22:14:35 +08:00
xiatian 57cca7ef71 fix(sheet): validate revision result contracts 2026-08-20 22:02:26 +08:00
昭逸 4372a8c5ba Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-20 21:44:16 +08:00
昭逸 422dc0fde3 fix ci fail to #666 2026-08-20 21:44:05 +08:00
muling.cs 3d59411a1a refresh slot repair org 2026-08-20 21:24:06 +08:00
chichuan fe66ac18a4 feat(policy): govern flag requiredness changes 2026-08-20 21:19:55 +08:00
毛球 f2a608d146 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 20:44:17 +08:00
Dennis 378b9f67a2 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 20:41:56 +08:00
xiatian 43ba466783 fix(sheet): require fresh confirmation for version revert 2026-08-20 20:28:35 +08:00
Dennis e6b06b561d Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 19:50:43 +08:00
xiatian 60a474f30c fix(sheet): fail closed on invalid revision results 2026-08-20 19:15:48 +08:00
xiatian 6e8fec5684 chore(policy): retire consumed flag migrations 2026-08-20 17:48:59 +08:00
毛球 e028d443d5 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 17:11:29 +08:00
xiatian 76a5559ca2 fix(sheet): align revision dry-run contract 2026-08-20 16:59:18 +08:00
昭逸 c4a1018213 删除无关文件 to #666 2026-08-20 16:55:18 +08:00
Dennis e6fe475aea chore: retire consumed chat flag migration 2026-08-20 16:44:07 +08:00
muling.cs e95ac52ff4 refresh org slot not only identity 2026-08-20 16:41:35 +08:00
Dennis 61f8140f91 test: close shortcut regression coverage gaps 2026-08-20 16:38:05 +08:00
昭逸 09c0cd7849 merge uptream to #666 2026-08-20 16:26:32 +08:00
xiatian 5a368a9ab8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 16:11:37 +08:00
Dennis fbcd8887ee fix: harden calendar todo and comment shortcuts 2026-08-20 16:09:18 +08:00
昭逸 7f4318a10d fix审批skill中附件描述 to #666 2026-08-20 15:39:14 +08:00
xiatian 5232f632c8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 15:35:19 +08:00
xiatian 615a775fdf feat(sheet): add revision changeset inspection 2026-08-20 15:34:45 +08:00
昭逸 cefc5c005c 附件上传dws合并为一个 to #666 2026-08-20 15:14:39 +08:00
昭逸 103b05413e 审批附件相关dws help补充 to #666 2026-08-20 10:40:35 +08:00
昭逸 169bbe88c0 上传附件dws to #666 2026-08-20 10:24:35 +08:00
86 changed files with 5763 additions and 447 deletions
@@ -0,0 +1,5 @@
---
category: Added
---
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
@@ -0,0 +1,7 @@
---
category: Fixed
---
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
@@ -0,0 +1,11 @@
---
category: Fixed
---
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
+23
View File
@@ -6,6 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.60-beta.1] - 2026-08-21
### Changed
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
### Fixed
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
## [1.0.59] - 2026-08-20
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.5"
version "1.0.60-beta.1"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz"
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
end
def install
+1 -1
View File
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
+41 -5
View File
@@ -1,6 +1,11 @@
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义两种受控 flag 迁移:
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
## 跨命令迁移原语
@@ -103,12 +110,12 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
@@ -157,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
```json
{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}
```
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
@@ -177,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -188,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
@@ -215,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
任何不相干漂移继续阻塞。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["oa.attachment_upload"]
if tool == nil {
t.Fatal("oa.attachment_upload is missing from final Schema")
}
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("interface_mode = %q, want composite", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Fatal("composite upload command must document an interface reason")
}
if got := schemaContractString(tool["effect"]); got != "write" {
t.Fatalf("effect = %q, want write", got)
}
if got := schemaContractString(tool["risk"]); got != "low" {
t.Fatalf("risk = %q, want low", got)
}
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
t.Fatalf("confirmation = %q, want not_required", got)
}
parameters := schemaContractMap(tool["parameters"])
for _, flag := range []string{"file", "file-name", "md5"} {
if parameters[flag] == nil {
t.Fatalf("upload --%s is missing from final Schema", flag)
}
}
if required, _ := parameters["file"]["required"].(bool); !required {
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
if properties["fileId"] == nil {
t.Fatal("upload Result data_schema is missing fileId")
}
}
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
+2
View File
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
+78 -1
View File
@@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
}
return oauthRefreshToken(p, ctx, data)
refreshed, rErr := oauthRefreshToken(p, ctx, data)
if rErr == nil || !isRefreshTokenRejected(rErr) {
return refreshed, rErr
}
// A stale identity slot can survive an older organization-only refresh.
// Retry once with the same-corp organization mirror while holding the
// existing dual lock; the fallback marks the publication so the rotated
// credential is written back into the mirror slot it consumed.
logging.AuthDebug(
"auth.refresh.fallback.triggered",
"corp_id", strings.TrimSpace(data.CorpID),
"user_id", strings.TrimSpace(data.UserID),
"error", rErr,
)
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
return nil, rErr
}
if p.logger != nil {
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
}
return fallback, nil
}
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
// the organization slot. The mirror must match the current corp, be valid,
// and differ from the rejected token. When both slots carry user identities,
// they must agree; legacy mirrors with an empty UserID are backfilled from the
// current identity before refresh.
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
if current == nil {
return nil, fmt.Errorf("no current token data")
}
corpID := strings.TrimSpace(current.CorpID)
if corpID == "" {
return nil, fmt.Errorf("current token has no corpId")
}
orgData, err := tokenLoadKeychainForCorpID(corpID)
if err != nil {
return nil, err
}
if orgData == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(orgData.CorpID) != corpID {
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
}
if !orgData.IsRefreshTokenValid() {
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
}
if orgData.RefreshToken == current.RefreshToken {
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
}
currentUserID := strings.TrimSpace(current.UserID)
orgUserID := strings.TrimSpace(orgData.UserID)
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
}
if orgUserID == "" {
orgData.UserID = current.UserID
orgData.UserName = current.UserName
}
// The refresh below consumes the mirror's refresh_token. Mark the
// publication so persistence writes the rotated credential back into the
// organization slot even under an explicit runtime selector whose plan
// would otherwise skip it (for example a preserved unresolved sibling).
orgData.RepairOrganizationMirror = true
refreshed, err := oauthRefreshToken(p, ctx, orgData)
if err != nil {
return nil, err
}
logging.AuthDebug(
"auth.refresh.fallback.success",
"corp_id", corpID,
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
)
return refreshed, nil
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
@@ -0,0 +1,427 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
{"plain error is unknown", errors.New("boom"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRefreshTokenRejected(tt.err); got != tt.want {
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
// and records refresh attempts plus organization slot lookups.
type orgSlotFallbackFixture struct {
provider *OAuthProvider
stale *TokenData
orgMirror *TokenData
renewed *TokenData
rejected *MCPTokenExchangeError
refreshErr error
orgRefreshErr error
refreshCalls []string
refreshUserIDs []string
orgLoads int
}
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
t.Helper()
isolateOAuthPersistence(t)
f := &orgSlotFallbackFixture{
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
stale: &TokenData{
AccessToken: "old-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
orgMirror: &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
renewed: &TokenData{
AccessToken: "new-access",
RefreshToken: "new-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
}
f.refreshErr = f.rejected
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
switch data.RefreshToken {
case "stale-refresh":
return nil, f.refreshErr
case "org-refresh":
return f.renewed, f.orgRefreshErr
}
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
f.orgLoads++
if corpID != "corp-1" {
return nil, ErrTokenDataNotFound
}
return f.orgMirror, nil
})
return f
}
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
}
if f.orgLoads != 1 {
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
}
}
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "legacy", CorpID: "corp-1", UserID: ""},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
},
}
selector := profileSelector("corp-1", "user-1")
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
}
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
t.Fatalf("repair marker did not force the organization slot write")
}
}
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
isolateOAuthPersistence(t)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
// Fake MCP refresh endpoint: the first call rejects the stale identity
// refresh_token with the reviewed business code; the second call (the
// organization mirror) succeeds and returns a rotated credential.
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if refreshCalls.Add(1) == 1 {
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
return
}
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
}))
defer srv.Close()
configDir := setupMCPConfigDir(t, srv.URL)
resetAppConfigCache()
// Seed the pre-fallback state: an identity slot whose refresh_token the
// server rejects, plus a legacy organization mirror (no userId) with a
// still-valid refresh_token and a preserved unresolved sibling profile so
// an explicit --profile refresh would normally skip the org slot.
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
},
}
if err := SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
orgMirror := &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
}
staleIdentity := &TokenData{
AccessToken: "stale-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
UserName: "User One",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
}
SetRuntimeProfile("corp-1:user-1")
t.Cleanup(func() { SetRuntimeProfile("") })
p := &OAuthProvider{
configDir: configDir,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Output: io.Discard,
httpClient: srv.Client(),
}
got, err := p.lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
}
if refreshCalls.Load() != 2 {
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
}
// The fallback consumed the mirror's refresh_token: both persisted slots
// must now carry the rotated credential instead of the consumed one.
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
}
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
}
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
}
if identitySlot.RefreshToken != "new-refresh" {
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
}
}
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
t.Run("transient failure does not fall back", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing org slot preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, ErrTokenDataNotFound
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, nil
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
}
})
t.Run("different user in org slot is rejected", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = "user-2"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
}
})
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = ""
f.orgMirror.UserName = ""
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
}
if f.refreshUserIDs[1] != "user-1" {
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
}
})
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshToken = "stale-refresh"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
}
})
t.Run("expired org refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing corp id skips fallback", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.stale.CorpID = ""
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
}
})
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
}
if f.orgLoads != 0 {
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
}
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
t.Fatal("refreshFromOrgSlot(nil) succeeded")
}
f.orgMirror.CorpID = "corp-2"
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
}
if len(f.refreshCalls) != 0 {
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
}
}
+13
View File
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
}
return RefreshFailureUnknown
}
// isRefreshTokenRejected reports whether the server returned a reviewed
// business code that definitively rejects the presented refresh_token.
// Only the reviewed MCP business code enables the organization-slot
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
// reviewed business code and deliberately do not trigger the fallback.
func isRefreshTokenRejected(err error) bool {
if err == nil {
return false
}
var exchangeErr *MCPTokenExchangeError
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
}
+16 -1
View File
@@ -107,6 +107,13 @@ type TokenData struct {
// transient marker to reject ambiguous UID-less logins without breaking
// legitimate refreshes of unresolved accounts.
FreshAuthorization bool `json:"-"`
// RepairOrganizationMirror marks a fallback refresh that consumed the
// organization mirror's refresh_token. The regular write plan can skip the
// organization slot under an explicit runtime selector (for example when an
// unresolved sibling profile still owns it), which would strand a
// refresh_token the server has already rotated; the marker forces the
// rotated credential back into that slot.
RepairOrganizationMirror bool `json:"-"`
}
// tokenPersistenceWritePlan is the single source of truth for deciding which
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
ExistingIdentity bool
UpgradesLegacyProfile bool
PreserveUnresolvedOrganization bool
RepairOrganizationMirror bool
WriteIdentity bool
WriteOrganization bool
WriteGlobal bool
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
!plan.UpgradesLegacyProfile
// A fallback refresh consumed the organization mirror's refresh_token;
// the rotated credential must go back into that slot even when the
// selector-driven plan would skip it (for example an explicit --profile
// that preserves an unresolved sibling profile's slot).
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
plan.WriteIdentity = plan.UserID != ""
orgCurrentSelector := ""
if cfg != nil {
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
// reauthorization. Its organization slot must move with the newly exact
// identity even when an explicit runtime selector keeps it from becoming
// process-global current.
plan.WriteOrganization = plan.UserID == "" ||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
plan.UserID == "" ||
plan.UpgradesLegacyProfile ||
(!plan.PreserveUnresolvedOrganization &&
(plan.MakeCurrent ||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
"persistence_profile", plan.PersistenceSelector,
"write_identity_slot", plan.WriteIdentity,
"write_org_mirror", plan.WriteOrganization,
"repair_org_mirror", plan.RepairOrganizationMirror,
"write_global_mirror", plan.WriteGlobal,
"publish_incoming_global", plan.MakeCurrent,
)
+7 -1
View File
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
if params == nil {
params = map[string]any{}
}
if err := validateSchemaValue("$", params, schema); err != nil {
if err := ValidateJSONSchemaValue(params, schema); err != nil {
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
}
return nil
}
// ValidateJSONSchemaValue validates one decoded JSON value against the
// required/type/enum/properties/items subset used by reviewed CLI contracts.
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
return validateSchemaValue("$", value, schema)
}
func validateSchemaValue(path string, value any, schema map[string]any) error {
if len(schema) == 0 {
return nil
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package commentreaction validates the reviewed DingTalk names accepted by
// comment emoji replies. The names mirror the bundled default emoji catalog;
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
package commentreaction
import (
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// Validate rejects values outside DingTalk's reviewed default reaction-name
// catalog. Names are case-sensitive because they are sent verbatim to the
// doc-comment service.
func Validate(value string) error {
name := strings.TrimSpace(value)
if value != name || !utf8.ValidString(name) || !supported(name) {
return apperrors.NewValidation(
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
apperrors.WithReason("unsupported_comment_reaction"),
)
}
return nil
}
func supported(name string) bool {
switch name {
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
return true
default:
return false
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package commentreaction
import "testing"
func TestCrossPlatformCoverageValidate(t *testing.T) {
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
if err := Validate(value); err != nil {
t.Errorf("supported reaction %q rejected: %v", value, err)
}
}
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
if err := Validate(value); err == nil {
t.Errorf("unsupported reaction %q accepted", value)
}
}
}
+4
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
return err
}
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
return err
}
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
"nodeId": nodeID,
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
+23
View File
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
}
}
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
for _, surface := range []string{"doc", "sheet"} {
for _, test := range []struct {
name string
args []string
}{
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
} {
t.Run(surface+"/"+test.name, func(t *testing.T) {
caller := &docCommentMutationCaller{}
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
t.Fatal("unsupported reaction accepted")
}
if len(caller.calls) != 0 {
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
}
})
}
}
}
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
for _, surface := range []struct {
name string
+4
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -3220,6 +3221,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
groupMentions, err := commentGroupMentionIDs(cmd)
if err != nil {
return err
+3 -3
View File
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", "oc-1",
)
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", " ",
)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji",
)
if err != nil {
+288 -4
View File
@@ -2,10 +2,13 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -18,6 +21,9 @@ import (
"github.com/spf13/cobra"
)
// computeFileMD5 is the function used to calculate file MD5. Tests can override via direct assignment.
var computeFileMD5 = fileMD5Hex
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
@@ -126,7 +132,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
}
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
return callOAAttachmentResultCtx(cmd.Context(), tool, args)
}
// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context
// (例如 upload 命令的 10 分钟超时):调用 oa/<tool>,提取 result 并包装为 output.Success。
func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args)
if err != nil {
return nil, err
}
@@ -141,10 +153,225 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any
return output.Success(result), nil
}
// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result
// 包含构造 DDAttachment 所需的全部必需字段,并将 spaceId/fileSize 归一化为声明的 integer
// 类型(int64),确保输出始终符合 ResultSpec schema。当字段缺失或类型错误时返回 Validation
// 错误,避免 Agent 拿到空 fileId 后组装无效的审批表单。
func validateOAAttachmentCommitResult(result any) (map[string]any, error) {
resultMap, ok := result.(map[string]any)
if !ok || resultMap == nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象")
}
// spaceId — 接受 string 或 number,归一化为 int64
switch v := resultMap["spaceId"].(type) {
case string:
if strings.TrimSpace(v) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
case float64:
// numeric spaceId — keep as-is (already matches JSON number)
case json.Number:
parsed, err := v.Int64()
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
// fileName — must be non-empty string
if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName")
}
// fileSize — must be > 0 (number),归一化 json.Number → int64
switch v := resultMap["fileSize"].(type) {
case float64:
if v <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
case json.Number:
parsed, err := v.Int64()
if err != nil || parsed <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
resultMap["fileSize"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize")
}
// fileId — must be non-empty string
if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId")
}
return resultMap, nil
}
// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) {
var data map[string]any
if err = json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err))
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result")
}
uploadKey, _ = result["uploadKey"].(string)
if strings.TrimSpace(uploadKey) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey")
}
rawURLs, ok := result["resourceUrls"].([]any)
if !ok || len(rawURLs) == 0 {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls")
}
resourceURL, _ = rawURLs[0].(string)
if strings.TrimSpace(resourceURL) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空")
}
rawHeaders, ok := result["headers"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers")
}
headers = make(map[string]string)
for key, value := range rawHeaders {
if str, ok := value.(string); ok {
headers[key] = str
}
}
if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date")
}
return resourceURL, headers, uploadKey, nil
}
// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节
// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并
// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。
func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
filePath := strings.TrimSpace(mustGetFlag(cmd, "file"))
if filePath == "" {
return apperrors.NewValidation("--file 不能为空")
}
info, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err))
}
if info.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath))
}
fileSize := info.Size()
fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name"))
if fileName == "" {
fileName = filepath.Base(filePath)
}
md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5"))
if md5Hex == "" {
md5Hex, err = computeFileMD5(filePath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err))
}
}
// --dry-run:本地只读工作(校验、Stat、大小、文件名、MD5)已完成,
// 在任何远程调用(init/PUT/commit)之前 early return,输出 plan 预览。
// 本命令是 RolloutUnifiedActive,必须经 output.StoreResult 存入统一结果,
// 不能直接 PrintJSON(否则框架报 "returned without a CommandResult")。
// 不伪造 uploadKey/resourceURL/fileId/spaceId——它们只能由远程调用返回。
if deps.Caller.DryRun() {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "attachment_upload",
"source": "oa",
"file": filePath,
"file_name": fileName,
"file_size": fileSize,
"md5": md5Hex,
"steps": []map[string]any{
{
"tool": "oa/init_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize, "md5": md5Hex},
"status": "planned",
},
{
"tool": "HTTP PUT",
"args": map[string]any{"file": filePath, "fileSize": fileSize},
"status": "planned",
},
{
"tool": "oa/commit_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize},
"requires": []string{"uploadKey from oa/init_attachment_upload_info"},
"status": "planned",
},
},
}, output.WithDryRun()))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。
initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"md5": md5Hex,
})
if err != nil {
return err
}
resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText)
if err != nil {
return err
}
// Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。
commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{
"fileName": fileName,
"uploadKey": uploadKey,
"fileSize": float64(fileSize),
})
if err != nil {
return err
}
commitResp, ok := commitData.(map[string]any)
if !ok {
return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象")
}
commitResult, _ := commitResp["result"]
normalized, err := validateOAAttachmentCommitResult(commitResult)
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), output.Success(normalized))
}
func newOAAttachmentCommand() *cobra.Command {
attachmentCmd := &cobra.Command{
Use: "attachment",
Short: "审批附件授权与下载链接",
Short: "审批附件授权、上传、下载与链接管理",
RunE: groupRunE,
}
@@ -319,7 +546,64 @@ func newOAAttachmentCommand() *cobra.Command {
},
})
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
uploadCmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)",
Long: `上传本地文件为审批附件(三步自动完成)。
流程:
1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info)
2. HTTP PUT 上传文件二进制到 OSS
3. 提交上传信息完成入库 (oa/commit_attachment_upload_info)
--file-name 不传时默认用文件名;--md5 不传时自动计算。`,
Example: ` dws oa approval attachment upload --file ./合同.pdf
dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx
dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`,
RunE: runOAAttachmentUpload,
}
DeclareLeafMetadata(uploadCmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "attachment_upload",
CanonicalPath: "oa.attachment_upload",
CLIPath: "oa approval attachment upload",
PrimaryCLIPath: "oa approval attachment upload",
},
Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步",
UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"},
AvoidWhen: []string{
"仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview",
"仅需获取已有附件临时下载链接时使用 attachment download-url",
},
Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"},
},
},
})
uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)")
uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)")
_ = uploadCmd.MarkFlagRequired("file")
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd)
return attachmentCmd
}
@@ -421,7 +705,7 @@ func validateOARequestTimeRange(request map[string]any) error {
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance, get_process_instances_by_admin,
// get_attachment_download_url, auth_download_file,
// auth_preview_attachment
// auth_preview_attachment, init_attachment_upload_info, commit_attachment_upload_info
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
+846
View File
@@ -10,7 +10,9 @@ import (
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
@@ -30,6 +32,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行")
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
@@ -337,3 +340,846 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
})
}
}
const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
func oaUploadCommitResponse(size int64) string {
return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
strconv.FormatInt(size, 10) +
`,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
}
// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
return path, int64(len(content))
}
// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
// resourceURL 与签名 headers。
type capturedPut struct {
calls int
url string
headers map[string]string
filePath string
fileSize int64
}
func mockOAAttachmentPut(t *testing.T) *capturedPut {
t.Helper()
put := &capturedPut{}
SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
put.calls++
put.url = url
put.headers = headers
put.filePath = filePath
put.fileSize = fileSize
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
return put
}
func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
const content = "pdf-bytes-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
}
wantInit := map[string]any{
"fileName": "合同.pdf",
"fileSize": float64(fileSize),
"md5": "d41d8cd98f00b204e9800998ecf8427e",
}
if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
}
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
if put.url != "https://oss.example.test/upload" {
t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
}
if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
}
if put.filePath != filePath || put.fileSize != fileSize {
t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
}
if caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
}
wantCommit := map[string]any{
"fileName": "合同.pdf",
"uploadKey": "key-123",
"fileSize": float64(fileSize),
}
if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok || data["fileId"] != "file-abc" {
t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
}
}
func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
const content = "auto-md5-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
// 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
); err != nil {
t.Fatalf("execute command: %v", err)
}
if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
}
if got := caller.argsLog[0]["md5"]; got != wantMD5 {
t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
}
if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
}
}
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) {
tests := []struct {
name string
response string
wantErr string
}{
{
name: "missing result",
response: `{"success":true}`,
wantErr: "缺少 result",
},
{
name: "missing uploadKey",
response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty uploadKey",
response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty resourceUrls",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 resourceUrls",
},
{
name: "empty resourceUrls element",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "首个 resourceUrls 为空",
},
{
name: "missing headers",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`,
wantErr: "缺少 headers",
},
{
name: "headers missing Authorization",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`,
wantErr: "Authorization",
},
{
name: "headers missing x-oss-date",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`,
wantErr: "x-oss-date",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: test.response},
// second step should never be reached
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "test.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
tests := []struct {
name string
args []string
}{
{name: "missing file", args: []string{"approval", "attachment", "upload"}},
{name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
{name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller, test.args...)
if err == nil {
t.Fatalf("%s unexpectedly succeeded", test.name)
}
if caller.calls != 0 {
t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证
// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、
// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。
func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) {
const content = "dry-run-preview-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
put := mockOAAttachmentPut(t)
// caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。
caller := &scriptedToolCaller{format: "json", dry: true}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--dry-run",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls)
}
if put.calls != 0 {
t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v\noutput: %s", err, stdout)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing/invalid: %#v", envelope["data"])
}
if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" {
t.Fatalf("plan flags = %#v", data)
}
if data["operation"] != "attachment_upload" || data["source"] != "oa" {
t.Fatalf("plan operation/source = %#v", data)
}
if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 {
t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5)
}
steps, ok := data["steps"].([]any)
if !ok || len(steps) != 3 {
t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"])
}
wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"}
for i, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
t.Fatalf("step %d not an object: %#v", i, raw)
}
if step["tool"] != wantTools[i] {
t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i])
}
if step["status"] != "planned" {
t.Fatalf("step %d status = %#v, want planned", i, step["status"])
}
}
// The commit step (index 2) must NOT contain uploadKey in args (remote-only field)
// and must declare the dependency via a "requires" entry.
commitStep := steps[2].(map[string]any)
commitArgs, _ := commitStep["args"].(map[string]any)
if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey {
t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs)
}
requires, ok := commitStep["requires"].([]any)
if !ok || len(requires) == 0 {
t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"])
}
found := false
for _, r := range requires {
if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) {
found = true
break
}
}
if !found {
t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to
// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows).
func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello")
put := mockOAAttachmentPut(t)
testseam.Swap(t, &computeFileMD5, func(string) (string, error) {
return "", errors.New("permission denied")
})
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
)
if err == nil {
t.Fatal("expected error from MD5 failure, got nil")
}
if !strings.Contains(err.Error(), "MD5") {
t.Fatalf("error should mention MD5, got: %v", err)
}
if caller.calls != 0 {
t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls)
}
if put.calls != 0 {
t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT,
// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。
func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data")
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
return errors.New("oss put failed")
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 不应被达到
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "put-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "oss put failed") {
t.Fatalf("error = %v, want oss put failed", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回
// 缺少必需字段时命令报错(而不是包装为成功)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantErr string
}{
{
name: "result is null",
commitResponse: `{"success":true,"result":null}`,
wantErr: "不是有效的 JSON 对象",
},
{
name: "result is empty object",
commitResponse: `{"success":true,"result":{}}`,
wantErr: "spaceId",
},
{
name: "missing fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`,
wantErr: "fileId",
},
{
name: "empty fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`,
wantErr: "fileId",
},
{
name: "missing spaceId",
commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`,
wantErr: "spaceId",
},
{
name: "fileSize is zero",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`,
wantErr: "fileSize",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-validate.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
// init + commit = 2 MCP calls
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
// PUT should have been called (init succeeded)
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用
// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的
// json.Number 类型分支。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) {
tests := []struct {
name string
input any
wantErr string // empty means expect nil error
}{
// result 不是 map
{name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"},
{name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"},
{name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"},
{name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"},
// spaceId — 空字符串
{name: "spaceId empty string", input: map[string]any{
"spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
{name: "spaceId whitespace only", input: map[string]any{
"spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// spaceId — json.Number(通过 UseNumber 解码的数字)
{name: "spaceId json.Number", input: map[string]any{
"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — float64(正常路径)
{name: "spaceId float64", input: map[string]any{
"spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — 非法类型
{name: "spaceId bool", input: map[string]any{
"spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// fileName — 非 string 类型
{name: "fileName is number", input: map[string]any{
"spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName is nil", input: map[string]any{
"spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName empty", input: map[string]any{
"spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
// fileSize — json.Number 有效
{name: "fileSize json.Number valid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1",
}, wantErr: ""},
// fileSize — json.Number 无效(<= 0)
{name: "fileSize json.Number zero", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number negative", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number invalid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1",
}, wantErr: "fileSize"},
// fileSize — 非法类型(default 分支)
{name: "fileSize is string", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize is nil", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1",
}, wantErr: "fileSize"},
// fileId — 非 string 类型
{name: "fileId is number", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99),
}, wantErr: "fileId"},
{name: "fileId whitespace", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ",
}, wantErr: "fileId"},
// 全部通过
{name: "all valid string types", input: map[string]any{
"spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc",
}, wantErr: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload
// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。
func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", "",
)
if err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("error = %v, want --file empty error", err)
}
if caller.calls != 0 {
t.Fatalf("MCP calls = %d, want 0", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload
// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。
func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{err: errors.New("injected init failure")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "init-err.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatal("expected error from init call failure, got nil")
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload
// 中 commitData 不是 map[string]any 的分支(line 348)。
func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data")
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 返回一个 JSON 字符串而非对象
{text: `"not an object"`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-nonobj.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") {
t.Fatalf("error = %v, want commit non-object error", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖
// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{not valid json`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "bad-init.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") {
t.Fatalf("error = %v, want parse failure", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证
// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) {
tests := []struct {
name string
input map[string]any
wantSpaceID any
wantSize any
wantErr string
}{
{
name: "string spaceId normalized to int64",
input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "non-numeric string spaceId returns error",
input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "float string spaceId returns error",
input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number spaceId normalized to int64",
input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "json.Number spaceId non-integer returns error",
input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number fileSize normalized to int64",
input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"},
wantSpaceID: float64(123),
wantSize: int64(2048),
},
{
name: "float64 spaceId unchanged",
input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: float64(27827223951),
wantSize: float64(100),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
normalized, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if normalized["spaceId"] != test.wantSpaceID {
t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID)
}
if normalized["fileSize"] != test.wantSize {
t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令
// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。
func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantSpaceID float64 // JSON decode 后 number → float64
}{
{
name: "spaceId as number",
commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
{
name: "spaceId as string",
commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const content = "output-contract-data"
filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content)
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing: %#v", envelope)
}
// spaceId must be a number in the JSON output
spaceID, ok := data["spaceId"].(float64)
if !ok {
t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"])
}
if spaceID != test.wantSpaceID {
t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID)
}
// fileSize must also be a number
if _, ok := data["fileSize"].(float64); !ok {
t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"])
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、
// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支:
// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。
func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{err: errors.New("commit upload failed")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "commit upload failed") {
t.Fatalf("error = %v, want commit upload failed", err)
}
if put.calls != 1 {
t.Fatalf("PUT calls = %d, want 1", put.calls)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog)
}
}
@@ -27,6 +27,11 @@ type scriptedToolCaller struct {
server string
tool string
args map[string]any
// Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit)
// can assert each invocation instead of only the last one captured above.
serverLog []string
toolLog []string
argsLog []map[string]any
}
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri
c.server = serverID
c.tool = toolName
c.args = args
c.serverLog = append(c.serverLog, serverID)
c.toolLog = append(c.toolLog, toolName)
c.argsLog = append(c.argsLog, args)
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
+6 -1
View File
@@ -26,10 +26,11 @@ func newSheetCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sheet",
Selection: contract.ProductSelectionDecl{
AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
AgentSummary: "导入本地 Excel,或创建、读取、编辑、导出和审计钉钉在线电子表格(axls),并管理工作表、区域、历史 revision、筛选、图表、图片与格式。",
UseWhen: []string{
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时",
"用户要查询工作簿当前 revision 或复核两个 revision 之间的 changeset 时",
},
AvoidWhen: []string{
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
@@ -110,6 +111,8 @@ func newSheetCommand() *cobra.Command {
dws sheet chart create 创建浮动图表
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
dws sheet revision-get 获取工作簿当前 revision
dws sheet changeset-get 获取工作簿 revision 区间内的 changeset
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
dws sheet import 导入 xlsx/xls 为在线电子表格
@@ -169,6 +172,7 @@ func newSheetCommand() *cobra.Command {
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
pivotTableCmd := newPivotTableCmd()
revisionCmds := newSheetRevisionCmds()
batchUpdateCmd := newBatchUpdateCmd()
DeclareLeafMetadata(batchUpdateCmd, LeafSpec{
@@ -244,6 +248,7 @@ func newSheetCommand() *cobra.Command {
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
standaloneCmds = append(standaloneCmds, revisionCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
// Register cross-product aliases
+4
View File
@@ -1,6 +1,7 @@
package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command {
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
toolArgs["emoji"] = true
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
@@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-1",
"--content", "heart",
"--content", "比心",
"--emoji",
"--mention", "uid1",
},
@@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "heart",
"content": "比心",
"replyCommentKey": "ck-1",
"emoji": true,
"mentionedUserIds": []string{"uid1"},
+783
View File
@@ -0,0 +1,783 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
const (
sheetRevisionGetRemoteTool = "get_sheet_revision"
sheetChangesetGetRemoteTool = "get_sheet_changeset"
sheetChangesetMaxSpan = int64(20)
sheetChangesetJSONMaxBytes = 2 * 1024 * 1024
)
var sheetRevisionResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"工作簿当前持久化 Delta revision",
"properties":{
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
"revision":{"type":"number","description":"当前工作簿 revision;空工作簿为 0"}
},
"required":["success","logId","revision"],
"additionalProperties":true
}`),
}
var sheetChangesetResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"工作簿 revision 区间内连续、按 revision 升序排列的 V2 前向语义 changeset",
"properties":{
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
"schemaVersion":{"type":"number","description":"changeset 业务响应的语义版本;当前固定为 2","enum":[2]},
"changeSemantics":{"type":"string","description":"变更详情只描述当时提交的前向效果,不提供统一 old/current 值;当前固定为 FORWARD_ONLY","enum":["FORWARD_ONLY"]},
"latestRevision":{"type":"number","description":"请求开始时观测并固定的工作簿最新 revision"},
"startRevision":{"type":"number","description":"查询基线 revision;结果不包含该 revision"},
"endRevision":{"type":"number","description":"本次实际查询的结束 revision;结果包含该 revision"},
"summary":{
"type":"object",
"description":"本次区间内语义 change 的完整性和影响范围汇总",
"properties":{
"changeCount":{"type":"number","description":"全部 changesets 中 change 对象的总数;STATE_RESET 不计作普通 change"},
"completeChangeCount":{"type":"number","description":"detailsStatus 为 COMPLETE 的 change 数量"},
"partialChangeCount":{"type":"number","description":"detailsStatus 为 PARTIAL 的 change 数量"},
"unsupportedChangeCount":{"type":"number","description":"type 为 UNSUPPORTED_CHANGE 的 change 数量"},
"containsStateReset":{"type":"boolean","description":"区间内是否包含 STATE_RESET 事件"},
"containsIncompleteChanges":{"type":"boolean","description":"区间内是否包含 PARTIAL、UNAVAILABLE 或 UNSUPPORTED_CHANGE"},
"affectedSheets":{
"type":"array",
"description":"由 AFFECTED 或 DESTINATION target 去重排序得到的工作表与 A1 范围摘要;SOURCE 不计入",
"items":{
"type":"object",
"properties":{
"sheetId":{"type":"string","description":"受影响工作表的稳定 ID"},
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称;缺失时不得由 sheetId 猜测"},
"ranges":{"type":"array","description":"去重排序后的 1-based A1 范围;工作表级变更为空数组","items":{"type":"string","description":"1-based A1 范围;整行或整列可写为 1:3 或 A:C"}}
},
"required":["sheetId","ranges"],
"additionalProperties":true
}
}
},
"required":["changeCount","completeChangeCount","partialChangeCount","unsupportedChangeCount","containsStateReset","containsIncompleteChanges","affectedSheets"],
"additionalProperties":true
},
"changesets":{
"type":"array",
"description":"区间 (startRevision, endRevision] 内按 revision 升序排列的完整事件",
"items":{
"type":"object",
"properties":{
"revision":{"type":"number","description":"该 changeset 对应的 Delta revision"},
"createTime":{"type":"string","description":"该 revision 的创建时间"},
"isSelfEdit":{"type":"boolean","description":"该 Delta 是否由当前请求用户提交;false 不能用于识别具体编辑者"},
"eventType":{"type":"string","description":"事件类型;EDIT 为普通前向编辑,UNDO 为撤销提交产生的前向效果,STATE_RESET 为全量状态替换点","enum":["EDIT","UNDO","STATE_RESET"]},
"detailsStatus":{"type":"string","description":"该事件内所有 change 的最差详情完整度;COMPLETE、PARTIAL 或 UNAVAILABLE","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
"reset":{
"type":"object",
"description":"STATE_RESET 的状态替换信息;EDIT 和 UNDO 不返回该对象",
"properties":{
"type":{"type":"string","description":"状态替换原因类型","enum":["ROLLBACK","OVERWRITE","UPGRADE","TEMPLATE","PRETTIFY","UNKNOWN_RESET"]},
"targetRevision":{"type":"number","description":"targetStatus 为 KNOWN 时的目标 revision;0 是合法空基线"},
"targetStatus":{"type":"string","description":"目标 revision 状态;KNOWN 表示已返回,NOT_APPLICABLE 表示该 reset 没有目标,UNAVAILABLE 表示应有目标但无法确认","enum":["KNOWN","NOT_APPLICABLE","UNAVAILABLE"]}
},
"required":["type","targetStatus"],
"additionalProperties":true
},
"changes":{
"type":"array",
"description":"该 revision 内按提交顺序排列的前向语义 change;STATE_RESET 固定为空数组",
"items":{
"type":"object",
"properties":{
"type":{"type":"string","description":"Agent 可解释的前向变更类型","enum":["ROWS_INSERTED","ROWS_DELETED","ROWS_UPDATED","COLUMNS_INSERTED","COLUMNS_DELETED","COLUMNS_UPDATED","SHEET_CREATED","SHEET_DELETED","SHEET_UPDATED","CUSTOM_TAB_ADDED","CUSTOM_TAB_DELETED","CUSTOM_TAB_UPDATED","CELLS_INSERTED","CELLS_DELETED","RANGE_PASTED","RANGE_AUTOFILLED","RANGE_CLEARED","RANGE_CONTENT_SET","RANGE_BORDER_SET","RANGE_STYLE_SET","RANGE_TAG_SET","RANGE_SORTED","CELLS_CONTENT_SET","CELLS_STYLE_SET","CELLS_TAG_SET","DIMENSION_GROUP_ADDED","DIMENSION_GROUP_REMOVED","DIMENSION_GROUP_UPDATED","DATA_VALIDATION_SET","DATA_VALIDATION_CLEARED","CELLS_MERGED","CELLS_UNMERGED","NAMED_RANGE_SET","NAMED_RANGE_CLEARED","FEATURE_ADDED","FEATURE_DELETED","FEATURE_UPDATED","WORKBOOK_SETTING_UPDATED","EXTERNAL_REFERENCES_REPLACED","UNSUPPORTED_CHANGE"]},
"targets":{
"type":"array",
"description":"该 change 涉及的工作簿、工作表或范围;源和目的范围用 role 区分",
"items":{
"type":"object",
"properties":{
"scope":{"type":"string","description":"定位层级","enum":["WORKBOOK","SHEET","RANGE"]},
"sheetId":{"type":"string","description":"SHEET 或 RANGE target 的稳定工作表 ID"},
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称"},
"sheetNameSource":{"type":"string","description":"工作表名称来源;AT_CHANGE 为变更时名称,CURRENT_STATE 为当前状态映射,UNKNOWN 为无法确认","enum":["AT_CHANGE","CURRENT_STATE","UNKNOWN"]},
"a1Range":{"type":"string","description":"RANGE target 的 1-based A1 范围"},
"role":{"type":"string","description":"target 在变更中的角色;省略时等价于 AFFECTED","enum":["AFFECTED","SOURCE","DESTINATION"]}
},
"required":["scope"],
"additionalProperties":true
}
},
"details":{
"type":"object",
"description":"按 change.type 归一后的前向详情;字段集合见 Sheet Skill,未返回的旧值或当前值不得推断",
"properties":{
"cell":{
"type":"object",
"description":"前向单元格内容;整体清除时为 {cleared:true}",
"properties":{
"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},
"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},
"formula":{"type":"string","description":"本次修改写入的可读公式文本"},
"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},
"cellType":{"type":"object","description":"单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
"link":{"type":"object","description":"工作簿内范围链接;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}
},
"additionalProperties":true
},
"cellType":{"type":"object","description":"相对内容变更的单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false},
"border":{"type":"object","description":"前向边框;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个边框对象被清除","enum":[true]},"color":{"description":"边框颜色;已知属性为 null 表示清除该属性"},"style":{"description":"边框线型;已知属性为 null 表示清除该属性"}},"additionalProperties":true},
"style":{"type":"object","description":"前向样式白名单;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true},
"properties":{"type":"object","description":"行列、工作表或自定义 Tab 的前向属性;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个属性对象被清除","enum":[true]}},"additionalProperties":true},
"changes":{"type":"object","description":"工作簿设置的前向字段;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个设置对象被清除","enum":[true]},"mode":{"type":"string","description":"CALCULATION 的重算模式;default 表示恢复默认模式","enum":["auto","autoNoTable","manual","default"]},"iterate":{"type":["boolean","null"],"description":"CALCULATION 是否启用迭代计算;null 表示清除该设置"},"iterateCount":{"type":["number","null"],"description":"CALCULATION 的最大迭代次数;null 表示清除该设置"},"iterateDelta":{"type":["number","null"],"description":"CALCULATION 的迭代收敛阈值;null 表示清除该设置"},"enableDynamicArray":{"type":["boolean","null"],"description":"CALCULATION 是否启用动态数组;null 表示清除该设置"},"date1904":{"type":["boolean","null"],"description":"CALCULATION 是否使用 1904 日期系统;null 表示清除该设置"},"image":{"type":"object","description":"BACKGROUND 图片设置;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示背景图片设置被清除","enum":[true]},"opacity":{"type":["number","null"],"description":"背景图片不透明度;null 表示清除该属性"}},"additionalProperties":true}},"additionalProperties":true},
"fillMode":{"type":"string","description":"RANGE_AUTOFILLED 的填充模式","enum":["copy","series","trend","predict","none"]},
"copyStyle":{"type":"boolean","description":"RANGE_AUTOFILLED 是否复制样式;当前协议的 COMPLETE change 必有"},
"styleMode":{"type":"string","description":"CELLS_STYLE_SET 的样式作用模式;coverStyle 未传 mode 时服务端显式返回 cell","enum":["sheet","row","col","cell"]},
"step":{"type":"object","description":"粘贴或自动填充模式块大小","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"}},"required":["rows","columns"],"additionalProperties":true},
"pasteMode":{"type":"string","description":"RANGE_PASTED 的粘贴作用模式","enum":["cell","row","col","sheet"]},
"isCut":{"type":"boolean","description":"RANGE_PASTED 是否来自剪切"},
"iterateMode":{"type":"string","description":"RANGE_PASTED 如何重复应用内容模式","enum":["step","flex"]},
"includedParts":{"type":"array","description":"RANGE_PASTED 实际包含非空数据的变更类别;空切片不计入","items":{"type":"string","description":"实际包含的变更类别","enum":["CONTENT","STYLE","MERGES","CELL_TYPES","CONDITIONAL_FORMATTING","TABLES","PIVOT_TABLES","COMMENTS","REMINDERS","MENTIONS","DATA_VALIDATION","FILTERS","LOCKS","FOLLOWERS","PROTECTION_RANGES","DIMENSION_METADATA","REACTIONS","RANGE_TAGS"]}},
"contentPattern":{"type":"object","description":"粘贴内容模式的安全化前向表示","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"},"cells":{"type":"array","description":"相对模式块的单元格内容","items":{"type":"object","properties":{"rowOffset":{"type":"number","description":"相对模式块左上角的 0-based 行偏移"},"columnOffset":{"type":"number","description":"相对模式块左上角的 0-based 列偏移"},"cleared":{"type":"boolean","description":"true 表示整个模式单元格被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"required":["rowOffset","columnOffset"],"additionalProperties":true}}},"required":["cells"],"additionalProperties":true},
"clearParts":{"type":"array","description":"RANGE_CLEARED 实际清除的内容类别","items":{"type":"string","description":"被清除的内容类别","enum":["VALUES","FORMULAS","MERGES","STYLES","CELL_TYPES","CONDITIONAL_FORMATTING","DATA_VALIDATION","DATA_VALIDATION_LIST","COLUMN_TYPES","LINKS","TABLES","COMMENTS","REMINDERS","REACTIONS"]}},
"preservedCellTypes":{"type":"array","description":"清除 CELL_TYPES 时明确保留的类型;空数组表示不保留 select 或 checkbox","items":{"type":"string","description":"保留的单元格类型","enum":["SELECT","CHECKBOX"]}},
"relativeChanges":{"type":"array","description":"相对 target 定位的前向行列属性、内容、样式或 tag 变更;sheet 级项可没有坐标","items":{"type":"object","properties":{"offset":{"type":"number","description":"ROWS_UPDATED 或 COLUMNS_UPDATED 中相对行列区间起点的 0-based 偏移"},"properties":{"type":"object","description":"offset 对应的前向行列属性或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个行列属性对象被清除","enum":[true]},"size":{"type":["number","null"],"description":"行高或列宽;null 表示清除该属性"},"customSize":{"type":["boolean","null"],"description":"是否使用自定义尺寸;null 表示清除该属性"},"hidden":{"type":["boolean","null"],"description":"是否隐藏;null 表示清除该属性"},"sticky":{"type":["boolean","null"],"description":"是否冻结;null 表示清除该属性"},"cellType":{"type":"object","description":"行列默认单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}},"additionalProperties":true},"rowOffset":{"type":"number","description":"0-based 行偏移;行级或单元格级项使用"},"columnOffset":{"type":"number","description":"0-based 列偏移;列级或单元格级项使用"},"cell":{"type":"object","description":"前向单元格内容或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"additionalProperties":true},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]}},"additionalProperties":true},"styleId":{"type":"string","description":"引用同一 change 的 styles[] 条目的 ID"},"styleCleared":{"type":"boolean","description":"true 表示该相对位置的样式被清除","enum":[true]},"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}},"additionalProperties":true}},
"styles":{"type":"array","description":"同一 change 内可由 relativeChanges.styleId 引用的具名样式条目","items":{"type":"object","properties":{"styleId":{"type":"string","description":"样式引用 ID"},"style":{"type":"object","description":"样式白名单对象;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}},"required":["styleId","style"],"additionalProperties":true}},
"dataValidation":{
"type":"object",
"description":"DATA_VALIDATION_SET 的归一化验证规则;敏感或无法安全解释的内部字段不会返回",
"properties":{
"type":{"type":"string","description":"数据验证规则类型"},
"templateId":{"type":"number","description":"列表验证使用的模板 ID;存在时不代表已返回内联选项或来源区域"},
"sourceType":{"type":"string","description":"下拉选项来源;inline 为内联选项,sourceRange 为区域引用","enum":["inline","sourceRange"]},
"options":{"type":"array","description":"安全化后的下拉选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}},
"sourceRange":{"type":"object","description":"解析成功时的下拉来源区域","properties":{"sheetId":{"type":"string","description":"来源工作表 ID"},"sheetName":{"type":"string","description":"可用时的来源工作表名称"},"a1Notation":{"type":"string","description":"来源区域的 1-based A1 表示"}},"additionalProperties":true},
"sourceRangeStatus":{"type":"string","description":"来源区域解析状态","enum":["RESOLVED","UNRESOLVED","INVALID"]},
"sourceRangeExpression":{"type":"string","description":"安全来源区域表达式;解析为 RESOLVED 时也可能保留原表达式"},
"enableMultiSelect":{"type":"boolean","description":"下拉是否允许多选"},
"criteria":{"type":"object","description":"非下拉规则可安全公开的具名条件参数","properties":{"operator":{"type":"string","description":"条件运算符"},"value1":{"type":"object","description":"第一个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"value2":{"type":"object","description":"第二个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"条件公式"}},"additionalProperties":true},
"settings":{"type":"object","description":"可安全公开的数据验证通用行为设置;已知属性为 null 表示清除该属性","properties":{"allowBlank":{"type":["boolean","null"],"description":"是否允许空值;null 表示清除该设置"},"errorStyle":{"type":["string","null"],"description":"验证失败时的错误样式;null 表示清除该设置"},"showInputMessage":{"type":["boolean","null"],"description":"是否显示输入提示;null 表示清除该设置"},"showErrorMessage":{"type":["boolean","null"],"description":"是否显示错误提示;null 表示清除该设置"},"prompt":{"type":["string","null"],"description":"输入提示文本;null 表示清除该设置"},"error":{"type":["string","null"],"description":"验证失败提示文本;null 表示清除该设置"},"requiredInRow":{"type":["boolean","null"],"description":"是否要求行内必填;null 表示清除该设置"},"showDropDown":{"type":["boolean","null"],"description":"是否显示下拉控件;null 表示清除该设置"},"columnType":{"type":["boolean","null"],"description":"是否启用列类型行为;null 表示清除该设置"},"promptTitle":{"type":["string","null"],"description":"输入提示标题;null 表示清除该设置"},"errorTitle":{"type":["string","null"],"description":"验证失败提示标题;null 表示清除该设置"},"imeMode":{"type":["string","null"],"description":"输入法模式;null 表示清除该设置"}},"additionalProperties":true}
},
"required":["type"],
"additionalProperties":true
}
},
"additionalProperties":true
},
"detailsStatus":{"type":"string","description":"details 的完整度;COMPLETE 仍只代表前向提交详情,不代表 old/current 状态完整","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
"omissions":{
"type":"array",
"description":"PARTIAL 或 UNAVAILABLE 时的稳定省略原因;COMPLETE 时固定为空数组",
"items":{"type":"object","properties":{"code":{"type":"string","description":"稳定的省略原因码","enum":["MISSING_REQUIRED_FIELD","PLUGIN_OPERATION","UNSUPPORTED_WRAPPER","UNSUPPORTED_PROTOCOL_VERSION","UNKNOWN_ACTION","UNKNOWN_VARIANT","DETAILS_NOT_FULLY_INTERPRETED","INVALID_TARGET","VALUE_NOT_AGENT_READABLE","FORMULA_TEXT_UNAVAILABLE","SOURCE_RANGE_UNRESOLVED","SOURCE_RANGE_INVALID","SENSITIVE_DETAILS_OMITTED"]},"fields":{"type":"array","description":"受该原因影响的 details 字段路径","items":{"type":"string","description":"被省略或不完整的字段路径"}}},"required":["code"],"additionalProperties":true}
}
},
"required":["type","targets","details","detailsStatus","omissions"],
"additionalProperties":true
}
}
},
"required":["revision","createTime","isSelfEdit","eventType","detailsStatus","changes"],
"additionalProperties":true
}
}
},
"required":["success","logId","schemaVersion","changeSemantics","latestRevision","startRevision","endRevision","summary","changesets"],
"additionalProperties":true
}`),
}
type sheetPublishedResultSchemaCache struct {
once sync.Once
schema map[string]any
err error
}
var (
sheetRevisionPublishedSchemaCache sheetPublishedResultSchemaCache
sheetChangesetPublishedSchemaCache sheetPublishedResultSchemaCache
)
func newSheetRevisionCmds() []*cobra.Command {
return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()}
}
func newSheetRevisionGetCmd() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "revision-get",
Short: "获取表格工作簿当前 revision",
Long: "获取在线电子表格工作簿当前持久化 revision。该能力是工作簿级的,不接收 --sheet-id;--node 可传文档 ID 或完整 URL。服务端业务 JSON 原样放入统一输出的 data。",
Example: " dws sheet revision-get --node <NODE_ID_OR_URL> --format json",
OutputRollout: output.RolloutUnifiedActive,
Server: "sheet",
Tool: sheetRevisionGetRemoteTool,
Flags: []LeafFlag{
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: sheetRevisionGetRemoteTool,
CanonicalPath: "sheet." + sheetRevisionGetRemoteTool,
CLIPath: "sheet revision-get",
PrimaryCLIPath: "sheet revision-get",
},
Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
Result: sheetRevisionResult,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetRevisionGetRemoteTool},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取在线电子表格工作簿当前 Delta revision,作为 changeset 查询锚点。",
UseWhen: []string{"需要知道当前 revision,或准备按 revision 区间复核工作簿编辑时"},
AvoidWhen: []string{"要查看可命名/可回滚的历史快照时用 sheet version list;要读当前单元格值时用 csv-get、table-get 或 range read"},
Examples: []string{"dws sheet revision-get --node <NODE_ID_OR_URL> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
},
},
ResultCall: callSheetRevisionResult,
})
}
func newSheetChangesetGetCmd() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "changeset-get",
Short: "获取表格工作簿 revision 区间内的 changeset",
Long: `获取在线电子表格工作簿在 (startRevision, endRevision] 区间内的连续、Agent 可解释的前向 changeset。
该能力是工作簿级的,不接收 --sheet-id。--end-revision 省略时由服务端在请求开始时固定为 latestRevision;单次区间最多 20 个 revision。统一输出 data.changesets 始终是可直接遍历的 JSON 数组;CLI 只解码服务端传输格式,不改写语义化 change。changeset 只描述当时提交的前向变更,不提供统一 old/current 值;确认最终状态必须另行回读。`,
Example: ` dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json
dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --format json`,
OutputRollout: output.RolloutUnifiedActive,
Server: "sheet",
Tool: sheetChangesetGetRemoteTool,
Flags: []LeafFlag{
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
{
Name: "start-revision", Usage: "查询基线 revision (必填,非负;结果不包含该 revision)", Bind: "startRevision",
Required: true, Trim: true, Example: "120", Transform: sheetRevisionNumberArg,
},
{
Name: "end-revision", Usage: "查询结束 revision (可选,非负;结果包含该 revision)", Bind: "endRevision",
Trim: true, OmitEmpty: true, Example: "121", Transform: sheetRevisionNumberArg,
},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: sheetChangesetGetRemoteTool,
CanonicalPath: "sheet." + sheetChangesetGetRemoteTool,
CLIPath: "sheet changeset-get",
PrimaryCLIPath: "sheet changeset-get",
},
Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
Result: sheetChangesetResult,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetChangesetGetRemoteTool},
},
Selection: contract.SelectionSpec{
AgentSummary: "读取工作簿两个 revision 之间的语义化前向 changeset,区分 EDIT、UNDO 与 STATE_RESET。",
UseWhen: []string{"已知起始 revision,需要复核之后发生了哪些工作簿级编辑或回滚时"},
AvoidWhen: []string{"要读取单元格当前最终值时用 csv-get、table-get 或 range read;要查看或回滚命名历史快照时用 sheet version list/revert"},
Examples: []string{"dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
{Name: "start-revision", Property: "startRevision", InterfaceType: "number", Description: "非负查询基线;返回区间不包含该 revision"},
{Name: "end-revision", Property: "endRevision", InterfaceType: "number", Description: "可选非负结束 revision;返回区间包含该 revision"},
},
},
Validate: validateSheetChangesetRange,
ResultCall: callSheetRevisionResult,
})
}
func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
if deps.Caller.DryRun() {
return output.Success(map[string]any{
"executed": false,
"tool": tool,
"arguments": args,
}, output.WithDryRun()), nil
}
raw, err := callMCPToolReturnTextOnServer(cmd.Context(), "sheet", tool, args)
if err != nil {
return nil, err
}
data, err := decodeSheetRevisionResult(tool, args, raw)
if err != nil {
return nil, err
}
return output.Success(data), nil
}
func decodeSheetRevisionResult(tool string, request map[string]any, raw string) (any, error) {
if strings.TrimSpace(raw) == "" {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned no non-empty text content",
"empty_tool_response",
true,
)
}
data, err := decodeSheetSingleJSON(raw)
if err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned invalid JSON: %v", err),
"invalid_tool_response",
false,
)
}
object, ok := data.(map[string]any)
if !ok {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned a non-object business result",
"invalid_tool_response",
false,
)
}
if isBusinessError(object) {
return nil, &CLIError{
Code: CodeMCPToolError,
Message: raw,
Suggestion: suggestForBusinessError(object),
Operation: "sheet/" + tool,
}
}
success, ok := object["success"].(bool)
if !ok || !success {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned a business result without success=true",
"invalid_tool_response",
false,
)
}
if tool == sheetChangesetGetRemoteTool {
if err := normalizeSheetChangesetTransport(object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned invalid changeset data: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
}
if err := validateSheetPublishedResult(tool, object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned data that does not match its published result contract: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
if tool == sheetChangesetGetRemoteTool {
if err := validateSheetChangesetAudit(request, object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned changeset data that does not match the requested interval or its summary: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
}
return object, nil
}
func invalidSheetRevisionResponse(tool, message, reason string, retryable bool) error {
return apperrors.NewAPI(message,
apperrors.WithOperation("sheet/"+tool),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(retryable),
apperrors.WithReason(reason),
)
}
func sheetResultLogIDSuffix(object map[string]any) string {
logID, ok := object["logId"].(string)
if !ok || strings.TrimSpace(logID) == "" {
return ""
}
return fmt.Sprintf(" (logId=%s)", strings.TrimSpace(logID))
}
func decodeSheetSingleJSON(raw string) (any, error) {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
var data any
if err := decoder.Decode(&data); err != nil {
return nil, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
err = fmt.Errorf("包含多个 JSON 值")
}
return nil, err
}
return data, nil
}
func normalizeSheetChangesetTransport(object map[string]any) error {
encoded, hasEncoded := object["changesetsJson"]
if hasEncoded {
changesetsJSON, ok := encoded.(string)
if !ok {
return fmt.Errorf("changesetsJson 不是字符串")
}
if strings.TrimSpace(changesetsJSON) == "" {
return fmt.Errorf("changesetsJson 为空")
}
if len(changesetsJSON) > sheetChangesetJSONMaxBytes {
return fmt.Errorf("changesetsJson 超过 %d 字节", sheetChangesetJSONMaxBytes)
}
decoded, err := decodeSheetSingleJSON(changesetsJSON)
if err != nil {
return fmt.Errorf("changesetsJson 不是完整 JSON: %v", err)
}
changesets, ok := decoded.([]any)
if !ok {
return fmt.Errorf("changesetsJson 根节点不是数组")
}
object["changesets"] = changesets
delete(object, "changesetsJson")
return nil
}
if legacy, exists := object["changesets"]; exists {
if _, ok := legacy.([]any); !ok {
return fmt.Errorf("changesets 不是数组")
}
return nil
}
return fmt.Errorf("成功响应缺少 changesetsJson")
}
func validateSheetPublishedResult(tool string, object map[string]any) error {
var rawSchema json.RawMessage
var cache *sheetPublishedResultSchemaCache
switch tool {
case sheetRevisionGetRemoteTool:
rawSchema = sheetRevisionResult.DataSchema
cache = &sheetRevisionPublishedSchemaCache
case sheetChangesetGetRemoteTool:
rawSchema = sheetChangesetResult.DataSchema
cache = &sheetChangesetPublishedSchemaCache
default:
return fmt.Errorf("未知工具 %q", tool)
}
return validateSheetPublishedResultWithSchema(tool, object, rawSchema, cache)
}
func validateSheetPublishedResultWithSchema(tool string, object map[string]any,
rawSchema json.RawMessage, cache *sheetPublishedResultSchemaCache,
) error {
cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) })
if cache.err != nil {
return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err)
}
schema := cache.schema
if err := cli.ValidateJSONSchemaValue(object, schema); err != nil {
return err
}
if strings.TrimSpace(object["logId"].(string)) == "" {
return fmt.Errorf("$.logId 不能为空")
}
switch tool {
case sheetRevisionGetRemoteTool:
if _, err := sheetNonNegativeInteger(object["revision"]); err != nil {
return fmt.Errorf("$.revision %v", err)
}
case sheetChangesetGetRemoteTool:
latest, err := sheetNonNegativeInteger(object["latestRevision"])
if err != nil {
return fmt.Errorf("$.latestRevision %v", err)
}
start, err := sheetNonNegativeInteger(object["startRevision"])
if err != nil {
return fmt.Errorf("$.startRevision %v", err)
}
end, err := sheetNonNegativeInteger(object["endRevision"])
if err != nil {
return fmt.Errorf("$.endRevision %v", err)
}
if start > end {
return fmt.Errorf("$.startRevision 不能大于 $.endRevision")
}
if end > latest {
return fmt.Errorf("$.endRevision 不能大于 $.latestRevision")
}
}
return nil
}
type sheetChangesetSummaryAudit struct {
changeCount int64
completeChangeCount int64
partialChangeCount int64
unsupportedChangeCount int64
containsStateReset bool
containsIncompleteChanges bool
affectedSheets []sheetChangesetAffectedSheetAudit
}
type sheetChangesetAffectedSheetAudit struct {
sheetID string
sheetName string
ranges []string
}
type sheetChangesetAffectedSheetAccumulator struct {
sheetName string
ranges map[string]struct{}
}
func validateSheetChangesetAudit(request, object map[string]any) error {
requestStart := request["startRevision"].(int64)
responseStart, _ := sheetNonNegativeInteger(object["startRevision"])
responseEnd, _ := sheetNonNegativeInteger(object["endRevision"])
latest, _ := sheetNonNegativeInteger(object["latestRevision"])
if responseStart != requestStart {
return fmt.Errorf("$.startRevision=%d,与请求值 %d 不一致", responseStart, requestStart)
}
if requestEnd, exists := request["endRevision"]; exists {
if responseEnd != requestEnd.(int64) {
return fmt.Errorf("$.endRevision=%d,与请求值 %d 不一致", responseEnd, requestEnd)
}
} else if responseEnd != latest {
return fmt.Errorf("省略 endRevision 时 $.endRevision=%d,必须等于 $.latestRevision=%d", responseEnd, latest)
}
if responseEnd-responseStart > sheetChangesetMaxSpan {
return fmt.Errorf("响应区间超过 %d 个 revision", sheetChangesetMaxSpan)
}
changesets := object["changesets"].([]any)
wantCount := responseEnd - responseStart
if int64(len(changesets)) != wantCount {
return fmt.Errorf("$.changesets 数量为 %d,无法完整覆盖 (%d,%d]", len(changesets), responseStart, responseEnd)
}
for index, rawChangeset := range changesets {
changeset := rawChangeset.(map[string]any)
revision, err := sheetNonNegativeInteger(changeset["revision"])
if err != nil {
return fmt.Errorf("$.changesets[%d].revision %v", index, err)
}
expected := responseStart + int64(index) + 1
if revision != expected {
return fmt.Errorf("$.changesets[%d].revision=%d,期望连续 revision %d", index, revision, expected)
}
}
actualSummary, err := parseSheetChangesetSummaryAudit(object["summary"].(map[string]any))
if err != nil {
return err
}
expectedSummary := summarizeSheetChangesetsForAudit(changesets)
if !reflect.DeepEqual(actualSummary, expectedSummary) {
return fmt.Errorf("$.summary 与 $.changesets 复算结果不一致")
}
return nil
}
func parseSheetChangesetSummaryAudit(raw map[string]any) (sheetChangesetSummaryAudit, error) {
fields := []string{"changeCount", "completeChangeCount", "partialChangeCount", "unsupportedChangeCount"}
counts := make([]int64, len(fields))
for index, field := range fields {
value, err := sheetNonNegativeInteger(raw[field])
if err != nil {
return sheetChangesetSummaryAudit{}, fmt.Errorf("$.summary.%s %v", field, err)
}
counts[index] = value
}
result := sheetChangesetSummaryAudit{
changeCount: counts[0],
completeChangeCount: counts[1],
partialChangeCount: counts[2],
unsupportedChangeCount: counts[3],
containsStateReset: raw["containsStateReset"].(bool),
containsIncompleteChanges: raw["containsIncompleteChanges"].(bool),
}
for _, rawSheet := range raw["affectedSheets"].([]any) {
sheet := rawSheet.(map[string]any)
rawRanges := sheet["ranges"].([]any)
affected := sheetChangesetAffectedSheetAudit{
sheetID: sheet["sheetId"].(string),
ranges: make([]string, 0, len(rawRanges)),
}
affected.sheetName, _ = sheet["sheetName"].(string)
for _, rawRange := range rawRanges {
affected.ranges = append(affected.ranges, rawRange.(string))
}
result.affectedSheets = append(result.affectedSheets, affected)
}
return result, nil
}
func summarizeSheetChangesetsForAudit(changesets []any) sheetChangesetSummaryAudit {
result := sheetChangesetSummaryAudit{}
affected := map[string]*sheetChangesetAffectedSheetAccumulator{}
for _, rawChangeset := range changesets {
changeset := rawChangeset.(map[string]any)
result.containsStateReset = result.containsStateReset || changeset["eventType"] == "STATE_RESET"
result.containsIncompleteChanges = result.containsIncompleteChanges || changeset["detailsStatus"] != "COMPLETE"
for _, rawChange := range changeset["changes"].([]any) {
change := rawChange.(map[string]any)
result.changeCount++
switch change["detailsStatus"] {
case "COMPLETE":
result.completeChangeCount++
case "PARTIAL":
result.partialChangeCount++
}
if change["type"] == "UNSUPPORTED_CHANGE" {
result.unsupportedChangeCount++
}
for _, rawTarget := range change["targets"].([]any) {
target := rawTarget.(map[string]any)
if target["role"] == "SOURCE" {
continue
}
sheetID, _ := target["sheetId"].(string)
if strings.TrimSpace(sheetID) == "" {
continue
}
accumulator := affected[sheetID]
if accumulator == nil {
accumulator = &sheetChangesetAffectedSheetAccumulator{ranges: map[string]struct{}{}}
affected[sheetID] = accumulator
}
if sheetName, ok := target["sheetName"].(string); ok && strings.TrimSpace(sheetName) != "" {
accumulator.sheetName = sheetName
}
if a1Range, ok := target["a1Range"].(string); ok && strings.TrimSpace(a1Range) != "" {
accumulator.ranges[a1Range] = struct{}{}
}
}
}
}
sheetIDs := make([]string, 0, len(affected))
for sheetID := range affected {
sheetIDs = append(sheetIDs, sheetID)
}
sort.Strings(sheetIDs)
for _, sheetID := range sheetIDs {
accumulator := affected[sheetID]
ranges := make([]string, 0, len(accumulator.ranges))
for a1Range := range accumulator.ranges {
ranges = append(ranges, a1Range)
}
sort.Strings(ranges)
result.affectedSheets = append(result.affectedSheets, sheetChangesetAffectedSheetAudit{
sheetID: sheetID, sheetName: accumulator.sheetName, ranges: ranges,
})
}
return result
}
func sheetNonNegativeInteger(value any) (int64, error) {
number, ok := value.(json.Number)
if !ok {
return 0, fmt.Errorf("必须是非负整数")
}
parsed, err := strconv.ParseInt(number.String(), 10, 64)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("必须是非负整数")
}
return parsed, nil
}
func sheetRevisionNumberArg(raw string) (any, error) {
return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
}
func validateSheetChangesetRange(cmd *cobra.Command, _ []string) error {
start, err := parseSheetRevisionFlag(cmd, "start-revision")
if err != nil {
return err
}
if start < 0 {
return apperrors.NewValidation("--start-revision 必须是非负整数")
}
endRaw, _ := cmd.Flags().GetString("end-revision")
if strings.TrimSpace(endRaw) == "" {
return nil
}
end, err := parseSheetRevisionFlag(cmd, "end-revision")
if err != nil {
return err
}
if end < 0 {
return apperrors.NewValidation("--end-revision 必须是非负整数")
}
if end < start {
return apperrors.NewValidation("--end-revision 必须大于或等于 --start-revision")
}
if end-start > sheetChangesetMaxSpan {
return apperrors.NewValidation(fmt.Sprintf(
"单次最多查询 %d 个 revision;请把 --end-revision 调整为不大于 %d",
sheetChangesetMaxSpan, start+sheetChangesetMaxSpan,
))
}
return nil
}
func parseSheetRevisionFlag(cmd *cobra.Command, name string) (int64, error) {
raw, _ := cmd.Flags().GetString(name)
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 64 位整数", name))
}
return value, nil
}
File diff suppressed because it is too large Load Diff
+13 -8
View File
@@ -111,9 +111,14 @@ func newSheetVersionCmd() *cobra.Command {
versionListCmd.Flags().String("cursor", "", "分页游标")
versionRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚表格到指定版本",
Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`,
Use: "revert",
Short: "[危险] 回滚表格到指定历史版本或 revision",
Long: `将在线表格恢复到指定历史版本或精确 revision。
通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时,
也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入
版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`,
Example: ` dws sheet version revert --node SHEET_ID --version 3`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -142,22 +147,22 @@ func newSheetVersionCmd() *cobra.Command {
CLIPath: "sheet version revert",
PrimaryCLIPath: "sheet version revert",
},
Description: "回滚表格到指定历史版本",
Description: "回滚表格到指定历史版本或已确认的精确 revision",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "回滚表格到指定历史版本",
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格"},
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert"},
AgentSummary: "回滚表格到指定历史版本或已确认的精确 revision",
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格,或明确要求恢复到同一工作簿中已确认的 revision"},
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert;目标 revision 未经同一工作簿的真实查询结果确认时不要猜测"},
Examples: []string{"dws sheet version revert --node <SHEET_ID> --version 3 --format json"},
},
},
})
versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
versionRevertCmd.Flags().Int("version", 0, "目标历史版本或已确认 revision (必填,通常从 version list 获取)")
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
c.Flags().String("url", "", "")
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"reflect"
"strings"
"testing"
)
func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) {
command := newSheetVersionCmd()
var output bytes.Buffer
command.SetOut(&output)
command.SetErr(&output)
command.SetArgs([]string{"revert", "--help"})
if err := command.Execute(); err != nil {
t.Fatalf("sheet version revert --help returned error: %v", err)
}
help := output.String()
for _, expected := range []string{
"通常应从 version list 选择已保存的历史版本",
"已从同一工作簿真实查询结果确认的 revision",
"禁止猜测 revision",
"目标历史版本或已确认 revision",
} {
if !strings.Contains(help, expected) {
t.Fatalf("sheet version revert help missing %q:\n%s", expected, help)
}
}
if strings.Contains(help, "--yes") {
t.Fatalf("sheet version revert help must not publish a confirmation-bypass example:\n%s", help)
}
}
func TestSheetVersionRevertRequiresConfirmationAndCallsExactToolWhenConfirmed(t *testing.T) {
args := []string{"version", "revert", "--node", "node-1", "--version", "37"}
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before confirmation, got %d: %+v", len(caller.calls), caller.calls)
}
caller = &guardedMutationCaller{}
confirmedArgs := append(append([]string(nil), args...), "--yes")
if err := executeGuardedMutationCommand(t, caller, newSheetCommand, confirmedArgs...); err != nil {
t.Fatalf("sheet version revert after confirmation returned error: %v", err)
}
want := guardedMutationCall{
productID: "doc",
toolName: "revert_doc_version",
args: map[string]any{
"nodeId": "node-1",
"version": 37,
},
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call after confirmation, got %d: %+v", len(caller.calls), caller.calls)
}
if !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool call = %#v, want %#v", caller.calls[0], want)
}
}
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
"已取消操作": "Operation cancelled",
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.",
"待办": "todo",
"待办任务 ID (必填)": "Todo task ID (required)",
"待办任务管理": "Todo task management",
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
"已取消操作": "已取消操作",
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态",
"待办": "待办",
"待办任务 ID (必填)": "待办任务 ID (必填)",
"待办任务管理": "待办任务管理",
+125 -18
View File
@@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
FlagMigrationRename = "flag_rename"
FlagMigrationRequirednessChange = "requiredness_change"
)
type FlagMigrationManifest struct {
@@ -25,11 +28,13 @@ type FlagMigrationManifest struct {
}
type FlagMigration struct {
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
State string `json:"state"`
Reason string `json:"reason"`
Kind string `json:"kind,omitempty"`
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
Flag *FlagMigrationSide `json:"flag,omitempty"`
State string `json:"state"`
Reason string `json:"reason"`
}
type FlagMigrationSide struct {
@@ -287,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error {
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
for _, requiredness := range m.Migrations {
if requiredness.EffectiveKind() != FlagMigrationRequirednessChange {
continue
}
for _, rename := range m.Migrations {
if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command {
continue
}
if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name {
return fmt.Errorf(
"flag requiredness migration %s overlaps rename migration %s",
requiredness.displayKey(),
rename.displayKey(),
)
}
}
}
return nil
}
@@ -294,15 +316,6 @@ func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
@@ -312,6 +325,30 @@ func (m FlagMigration) validate() error {
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
switch m.EffectiveKind() {
case FlagMigrationRename:
return m.validateRename()
case FlagMigrationRequirednessChange:
return m.validateRequirednessChange()
default:
return fmt.Errorf("invalid kind %q", m.Kind)
}
}
func (m FlagMigration) validateRename() error {
if m.Flag != nil {
return fmt.Errorf("flag rename must not declare flag requiredness fields")
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
@@ -392,6 +429,45 @@ func (m FlagMigration) validate() error {
return nil
}
func (m FlagMigration) validateRequirednessChange() error {
if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) {
return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields")
}
if m.Flag == nil || !isExactFlagName(m.Flag.Name) {
return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag))
}
if err := m.Flag.Before.validate("flag before"); err != nil {
return err
}
if err := m.Flag.After.validate("flag after"); err != nil {
return err
}
if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required {
return fmt.Errorf("flag requiredness migration must change exactly from optional to required")
}
if m.Flag.Before.Hidden || m.Flag.After.Hidden {
return fmt.Errorf("flag requiredness migration flag must stay visible")
}
if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" {
return fmt.Errorf("flag requiredness migration must not declare alias_of")
}
before := m.Flag.Before
after := m.Flag.After
before.Required = false
after.Required = false
if before != after {
return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness")
}
return nil
}
func flagMigrationSideName(side *FlagMigrationSide) string {
if side == nil {
return ""
}
return side.Name
}
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
@@ -409,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error {
}
func (m FlagMigration) key() string {
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag)
}
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
}
// EffectiveKind keeps manifests written before kinds were introduced valid.
// An omitted kind is the original flag rename primitive.
func (m FlagMigration) EffectiveKind() string {
if m.Kind == "" {
return FlagMigrationRename
}
return m.Kind
}
func isExactCommandPath(path string) bool {
@@ -428,9 +516,9 @@ func isExactFlagName(name string) bool {
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
// records participate in the lifecycle check, but never authorize their own
// interface change.
// then consumes only exact, merge-base-owned flag rename or requiredness
// migrations. Candidate-owned records participate in the lifecycle check, but
// never authorize their own interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
@@ -640,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig
if !exists {
return flagMigrationPartial
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag))
if state == migration.Flag.Before {
return flagMigrationBefore
}
if state == migration.Flag.After {
return flagMigrationAfter
}
return flagMigrationPartial
}
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
@@ -694,6 +792,12 @@ func flagMigrationAuthorizesChange(
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" {
return true
}
continue
}
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
@@ -717,5 +821,8 @@ func flagMigrationAuthorizesChange(
}
func (m FlagMigration) displayKey() string {
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag))
}
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
@@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{
]
}`
const validRequirednessMigrationManifestJSON = `{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}`
func optionalFlagMigrationManifestJSON() string {
manifest := strings.Replace(
validFlagMigrationManifestJSON,
@@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
}
if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err)
}
tests := []struct {
name string
@@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
}
}
func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "unknown kind",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1),
wantErr: "invalid kind",
},
{
name: "missing flag name",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1),
wantErr: "exact flag",
},
{
name: "invalid before state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "scope": "anything"}`,
1,
),
wantErr: "flag before present state has invalid scope",
},
{
name: "invalid after state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`,
1,
),
wantErr: "flag after present state has invalid scope",
},
{
name: "before already required",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "after remains optional",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "type drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`,
1,
),
wantErr: "must preserve every flag attribute except requiredness",
},
{
name: "visibility drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`,
1,
),
wantErr: "must stay visible",
},
{
name: "alias relation",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`,
1,
),
wantErr: "must not declare alias_of",
},
{
name: "rename fields cannot be combined",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"flag": {`,
`"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`,
1,
),
wantErr: "must not declare legacy or canonical",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
missingFlag := FlagMigration{
Kind: FlagMigrationRequirednessChange,
Command: "dws report entry submit",
State: FlagMigrationPending,
Reason: "Missing exact flag state.",
}
if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") {
t.Fatalf("missing requiredness flag error = %v", err)
}
rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
rename.Migrations[0].Kind = FlagMigrationRename
rename.Migrations[0].Flag = &FlagMigrationSide{
Name: "unrelated",
Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
}
if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") {
t.Fatalf("rename with requiredness fields error = %v", err)
}
}
func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) {
pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
migration := pending.Migrations[0]
before := requirednessMigrationSnapshot(migration, false)
after := requirednessMigrationSnapshot(migration, true)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) {
t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err)
}
partial := after
partial.Commands = append([]Command(nil), after.Commands...)
partial.Commands[len(partial.Commands)-1].LocalFlags = append(
[]Flag(nil),
after.Commands[len(after.Commands)-1].LocalFlags...,
)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice"
if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial {
t.Fatalf("drifted requiredness migration phase = %s, want partial", phase)
}
beforeWithUnrelated := before
beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...)
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append(
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string"},
)
afterWithUnrelated := after
afterWithUnrelated.Commands = append([]Command(nil), after.Commands...)
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append(
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string", Required: true},
)
report, err = CompareAllWithFlagMigrations(
afterWithUnrelated,
map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated},
pending,
consumed,
)
if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") {
t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err)
}
empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
if _, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("candidate self-authorization error = %v", err)
}
}
func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot {
state := migration.Flag.Before
if after {
state = migration.Flag.After
}
flag := Flag{
Name: migration.Flag.Name,
Type: state.Type,
Required: state.Required,
Hidden: state.Hidden,
Shorthand: state.Shorthand,
NoOpt: state.NoOpt,
}
command := testCommandWithFlagScopes(migration.Command, nil, nil)
if state.Scope == "inherited" {
command.InheritedFlags = []Flag{flag}
} else {
command.LocalFlags = []Flag{flag}
}
return testSnapshot(testCommand("dws"), command)
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
@@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
overlap := manifest
overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
rename := overlap.Migrations[0]
rename.Command = requiredness.Migrations[0].Command
rename.Canonical.Name = requiredness.Migrations[0].Flag.Name
rename.Legacy.After.AliasOf = rename.Canonical.Name
overlap.Migrations[0] = rename
overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0])
if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") {
t.Fatalf("requiredness and rename overlap error = %v", err)
}
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
@@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
}
var serviceEmptySentinel map[string]any
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil {
t.Fatal(err)
}
events, page, err = eventListProject(serviceEmptySentinel)
if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err)
}
for name, payload := range map[string]string{
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
"bad collection": `{"success":true,"result":{"events":{}}}`,
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
"missing pagination": `{"success":true,"result":{"events":[]}}`,
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
"bad collection": `{"success":true,"result":{"events":{}}}`,
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
"unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`,
"sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`,
"missing pagination": `{"success":true,"result":{"events":[]}}`,
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
} {
t.Run(name, func(t *testing.T) {
var data map[string]any
+4
View File
@@ -16,6 +16,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
@@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st
if !ok {
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
if key == "events" {
items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container)
}
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
return nil, nil, err
}
@@ -0,0 +1,48 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package calendarcompat contains narrow compatibility adapters for observed
// Calendar wire shapes shared by the calendar and smart Shortcut packages.
package calendarcompat
// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty
// page sentinel. Some deployments encode an exhausted empty page as one object
// whose collection fields are all null instead of events:[]. Only that exact
// terminal shape is normalized; arbitrary id-less or mixed event rows remain
// available to the strict caller validation and must fail closed.
func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) {
if len(items) != 1 || container == nil {
return items, false
}
hasMore, ok := container["hasMore"].(bool)
if !ok || hasMore || hasNonEmptyCursor(container) {
return items, false
}
placeholder, ok := items[0].(map[string]any)
knownNullFields := map[string]struct{}{
"attendees": {},
"categories": {},
"meetingRooms": {},
"reminders": {},
}
if !ok || len(placeholder) != len(knownNullFields) {
return items, false
}
for key, value := range placeholder {
if _, known := knownNullFields[key]; !known || value != nil {
return items, false
}
}
return []any{}, true
}
func hasNonEmptyCursor(container map[string]any) bool {
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
value, present := container[key]
if !present || value == nil || value == "" {
continue
}
return true
}
return false
}
@@ -0,0 +1,44 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendarcompat
import "testing"
func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) {
sentinel := []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}}
if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 {
t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok)
}
for name, items := range map[string][]any{
"empty object": {map[string]any{}},
"missing fields": {map[string]any{"attendees": nil}},
"unknown field": {map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil,
}},
"non-null field": {map[string]any{
"attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"mixed rows": {sentinel[0], map[string]any{"id": "event-1"}},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok {
t.Fatalf("invalid sentinel accepted: %#v", items)
}
})
}
for name, container := range map[string]map[string]any{
"missing terminal evidence": {},
"more pages": {"hasMore": true},
"cursor": {"hasMore": false, "nextCursor": "next"},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok {
t.Fatalf("non-terminal sentinel accepted with %#v", container)
}
})
}
}
+4
View File
@@ -19,6 +19,7 @@ package doc
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
@@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{
"replyCommentKey": rt.Str("comment-key"),
}
if rt.Bool("emoji") {
if err := commentreaction.Validate(rt.Str("content")); err != nil {
return err
}
params["emoji"] = true
}
if rt.Changed("mention") {
@@ -132,6 +132,31 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc
return root.Execute()
}
func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) {
for _, content := range []string{"😄", "乱码"} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", content, "--emoji", "--yes")
if err == nil {
t.Fatalf("unsupported reaction %q accepted", content)
}
if caller.calls != 0 {
t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls)
}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", "鼓掌", "--emoji", "--yes"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.history[0].params["emoji"] != true {
t.Fatalf("valid reaction call = %#v", caller.history)
}
}
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
@@ -23,6 +23,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarSmartMaxPages = 20
@@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string
if !hasMore && next != "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
}
list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result)
events := make([]map[string]any, 0, len(list))
for index, item := range list {
event, ok := item.(map[string]any)
@@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
items, hasMore, next, err = calendarSmartEventPage(map[string]any{
"success": true,
"result": map[string]any{
"events": []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"hasMore": false,
},
})
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
invalidPages := []map[string]any{
{},
@@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": nil}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
}
for index, page := range invalidPages {
+44 -1
View File
@@ -6,6 +6,7 @@ package todo
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -159,13 +160,55 @@ func updateTodo(rt *shortcut.RuntimeContext) error {
if key == "taskId" {
continue
}
if fmt.Sprint(detail[key]) != fmt.Sprint(expected) {
if !todoUpdateFieldMatches(key, detail[key], expected) {
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
}
}
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
}
func todoUpdateFieldMatches(key string, actual, expected any) bool {
switch key {
case "dueTime", "priority":
actualNumber, actualOK := todoExactInteger(actual)
expectedNumber, expectedOK := todoExactInteger(expected)
return actualOK && expectedOK && actualNumber == expectedNumber
case "subject":
actualText, actualOK := actual.(string)
expectedText, expectedOK := expected.(string)
return actualOK && expectedOK && actualText == expectedText
default:
return false
}
}
func todoExactInteger(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int64:
return typed, true
case float64:
return todoExactFloat(typed)
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed, err == nil
default:
return 0, false
}
}
func todoExactFloat(value float64) (int64, bool) {
if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value ||
value < float64(math.MinInt64) || value >= float64(math.MaxInt64) {
return 0, false
}
return int64(value), true
}
var Update = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "todo", Command: "+update", Product: "todo",
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"io"
"math"
"strconv"
"strings"
"testing"
@@ -313,11 +314,44 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
}
updateSuccess := &todoCoverageCaller{responses: map[string][]string{
"update_todo_task": {`{"success":true}`},
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`},
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40,"dueTime":1786932000000}}}`},
}}
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil {
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--due", todoCoverageTime, "--yes"); err != nil {
t.Fatal(err)
}
if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) {
t.Fatal("different dueTime milliseconds matched")
}
for _, tc := range []struct {
value any
want int64
ok bool
}{
{value: int(40), want: 40, ok: true},
{value: int64(1786932000000), want: 1786932000000, ok: true},
{value: float64(40), want: 40, ok: true},
{value: json.Number("40"), want: 40, ok: true},
{value: " 40 ", want: 40, ok: true},
{value: float64(math.MinInt64), want: math.MinInt64, ok: true},
{value: 1786932000000.5},
{value: math.NaN()},
{value: math.Inf(1)},
{value: math.Inf(-1)},
{value: float64(math.MaxInt64)},
{value: 1e300},
{value: -1e300},
{value: json.Number("not-a-number")},
{value: "not-a-number"},
{value: true},
} {
got, ok := todoExactInteger(tc.value)
if got != tc.want || ok != tc.ok {
t.Fatalf("todoExactInteger(%#v) = %d/%v, want %d/%v", tc.value, got, ok, tc.want, tc.ok)
}
}
if todoUpdateFieldMatches("unknown", 1, 1) {
t.Fatal("unknown update field matched")
}
}
func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) {
@@ -1080,6 +1080,29 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChec
t.Fatalf("lineage hid positional drift: %s", failures)
}
requiredness := interfacesnapshot.FlagMigration{
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
Command: "dws report cli-only",
Flag: &interfacesnapshot.FlagMigrationSide{
Name: "recipient",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "Exercise requiredness receipts beside command lineage without treating them as renames.",
}
flagsWithRequiredness := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
flagsWithRequiredness = append(flagsWithRequiredness, requiredness)
if _, err := normalizeSchemaCommandMigrationLineage(
stable,
base,
current,
flagsWithRequiredness,
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
); err != nil {
t.Fatalf("requiredness receipt interfered with command lineage: %v", err)
}
// Multiple historical names may converge only when every predecessor carries
// the exact same contract and every receipt is already consumed.
stable, base, current = schemaCommandLineageContracts()
+76 -3
View File
@@ -1261,9 +1261,9 @@ type schemaToolRef struct {
}
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
// rename onto a cloned historical Schema contract. The ordinary compatibility
// checker still makes the final decision; this adapter never drops findings by
// matching their rendered text.
// rename or requiredness change onto a cloned historical Schema contract. The
// ordinary compatibility checker still makes the final decision; this adapter
// never drops findings by matching their rendered text.
func normalizeSchemaFlagMigrations(
baseline schemaContract,
current schemaContract,
@@ -1273,6 +1273,14 @@ func normalizeSchemaFlagMigrations(
renamesByTool := map[schemaToolRef]map[string]string{}
for _, migration := range migrations {
if migration.EffectiveKind() == interfacesnapshot.FlagMigrationRequirednessChange {
var err error
normalized, err = normalizeSchemaFlagRequirednessMigration(normalized, baseline, current, migration)
if err != nil {
return schemaContract{}, err
}
continue
}
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
@@ -1394,6 +1402,65 @@ func normalizeSchemaFlagMigrations(
return normalized, nil
}
func normalizeSchemaFlagRequirednessMigration(
normalized schemaContract,
baseline schemaContract,
current schemaContract,
migration interfacesnapshot.FlagMigration,
) (schemaContract, error) {
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
// A reviewed CLI-only command has no Schema compatibility surface.
return normalized, nil
}
if len(matches) != 1 {
return schemaContract{}, fmt.Errorf(
"approved flag requiredness migration %q matches %d historical Schema tools",
migration.Command,
len(matches),
)
}
ref := matches[0]
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
oldParameter, existed := oldTool.Parameters[migration.Flag.Name]
if !existed {
// CLI requiredness is not authority to create or mutate an unrelated
// historical Schema parameter.
return normalized, nil
}
newProduct, productExists := current.Products[ref.productID]
newTool, toolExists := newProduct.Tools[ref.toolID]
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
// Preserve the baseline so the ordinary checker reports the missing tool
// or primary path change.
return normalized, nil
}
newParameter, exists := newTool.Parameters[migration.Flag.Name]
if !exists {
// Preserve the baseline so the ordinary checker reports parameter loss.
return normalized, nil
}
if !newParameter.Required || !newParameter.CLIRequired {
return schemaContract{}, fmt.Errorf(
"approved flag requiredness migration %q Schema parameter %q must be required and cli_required",
migration.Command,
migration.Flag.Name,
)
}
normalizedProduct := normalized.Products[ref.productID]
normalizedTool := normalizedProduct.Tools[ref.toolID]
normalizedParameter := oldParameter
normalizedParameter.Required = newParameter.Required
normalizedParameter.CLIRequired = newParameter.CLIRequired
normalizedTool.Parameters[migration.Flag.Name] = normalizedParameter
normalizedProduct.Tools[ref.toolID] = normalizedTool
normalized.Products[ref.productID] = normalizedProduct
return normalized, nil
}
// normalizeSchemaCommandMigrationLineage composes two independently reviewed
// migration receipts without inventing a second alias authority. A consumed
// flag migration may supply the historical predecessor of a command_move
@@ -1482,6 +1549,9 @@ func stageSchemaCommandMigrationPredecessors(
for _, parameter := range migration.Schema.Parameters {
if _, direct := oldTool.Parameters[parameter.From]; direct {
for _, flagMigration := range flagMigrations {
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
continue
}
if flagMigration.Command != migration.Legacy.Command ||
flagMigration.Canonical.Name != parameter.From {
continue
@@ -1501,6 +1571,9 @@ func stageSchemaCommandMigrationPredecessors(
predecessors := make([]interfacesnapshot.FlagMigration, 0, 1)
for _, flagMigration := range flagMigrations {
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
continue
}
if flagMigration.Command != migration.Legacy.Command ||
flagMigration.Canonical.Name != parameter.From {
continue
+138
View File
@@ -1229,6 +1229,144 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin
}
}
func TestCrossPlatformCoverageSchemaFlagRequirednessMigration(t *testing.T) {
baseline := schemaContract{
Version: schemaContractVersion,
Products: map[string]productSchema{
"report": {
Tools: map[string]toolSchema{
"report.entry_submit": {
PrimaryCLIPath: "report entry submit",
Parameters: map[string]parameterSchema{
"to-user-ids": {Type: `"string"`, Property: "toUserIds", InterfaceType: "string"},
},
},
},
},
},
}
current := cloneContract(baseline)
product := current.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.Required = true
parameter.CLIRequired = true
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
current.Products["report"] = product
migration := interfacesnapshot.FlagMigration{
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
Command: "dws report entry submit",
Flag: &interfacesnapshot.FlagMigrationSide{
Name: "to-user-ids",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "Reject report submissions that have no visible recipient.",
}
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
t.Fatalf("exact requiredness migration should pass after normalization: %v", failures)
}
t.Run("non-requiredness drift remains blocked", func(t *testing.T) {
drifted := cloneContract(current)
product := drifted.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.Type = `"array"`
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
drifted.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(baseline, drifted, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, drifted), "\n"); !strings.Contains(failures, "changed type") {
t.Fatalf("requiredness migration hid type drift: %s", failures)
}
})
t.Run("partial Schema projection fails closed", func(t *testing.T) {
partial := cloneContract(current)
product := partial.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.CLIRequired = false
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
partial.Products["report"] = product
if _, err := normalizeSchemaFlagMigrations(baseline, partial, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "required and cli_required") {
t.Fatalf("partial requiredness projection error = %v", err)
}
})
t.Run("CLI-only migration does not manufacture Schema", func(t *testing.T) {
cliOnly := migration
cliOnly.Command = "dws report cli-only"
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{cliOnly})
if err != nil || !reflect.DeepEqual(normalized, baseline) {
t.Fatalf("CLI-only requiredness migration = %#v, %v", normalized, err)
}
})
t.Run("duplicate historical path fails closed", func(t *testing.T) {
duplicate := cloneContract(baseline)
product := duplicate.Products["report"]
product.Tools["report.duplicate"] = product.Tools["report.entry_submit"]
duplicate.Products["report"] = product
if _, err := normalizeSchemaFlagMigrations(duplicate, current, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "matches 2") {
t.Fatalf("duplicate requiredness Schema path error = %v", err)
}
})
t.Run("missing historical parameter is not authority", func(t *testing.T) {
missing := cloneContract(baseline)
product := missing.Products["report"]
tool := product.Tools["report.entry_submit"]
delete(tool.Parameters, "to-user-ids")
product.Tools["report.entry_submit"] = tool
missing.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(missing, current, []interfacesnapshot.FlagMigration{migration})
if err != nil || !reflect.DeepEqual(normalized, missing) {
t.Fatalf("missing historical requiredness parameter = %#v, %v", normalized, err)
}
})
t.Run("missing current tool stays visible to ordinary checker", func(t *testing.T) {
missing := cloneContract(current)
delete(missing.Products, "report")
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := checkCompatibility(normalized, missing); len(failures) == 0 {
t.Fatal("requiredness migration hid missing current Schema tool")
}
})
t.Run("missing current parameter stays visible to ordinary checker", func(t *testing.T) {
missing := cloneContract(current)
product := missing.Products["report"]
tool := product.Tools["report.entry_submit"]
delete(tool.Parameters, "to-user-ids")
product.Tools["report.entry_submit"] = tool
missing.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, missing), "\n"); !strings.Contains(failures, "lost parameter") {
t.Fatalf("requiredness migration hid missing current parameter: %s", failures)
}
})
}
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) {
tests := []struct {
name string
+31 -4
View File
@@ -27,13 +27,14 @@ Flags:
--instance-id string 审批实例 ID (必填)
```
### 审批附件授权与下载
### 审批附件授权、上传与下载
先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -78,6 +79,21 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
#### 上传本地文件为审批附件
```
Usage:
dws oa approval attachment upload [flags]
Example:
dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
--file string 本地文件路径 (必填)
--file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。
@@ -292,7 +308,7 @@ Flags:
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file <path>` 获取字段再组装为 value 提交,不属于不支持类型。
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。
- **严禁在未得到用户确认前直接执行真实提单。**
@@ -411,7 +427,7 @@ Flags:
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file <path>` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 |
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
@@ -426,9 +442,19 @@ Flags:
- `CalculateField`(计算公式)— 由系统自动计算
- `SeqNumberField`(流水号)— 由系统自动生成
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file <path>` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例:
>
> ```bash
> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType
> dws oa approval attachment upload --file ./a.pdf
> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes
> dws oa approval create-instance --process-code PROC-xxx \
> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}'
> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes
> ```
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
@@ -745,6 +771,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
@@ -239,14 +239,14 @@
### DDAttachment(附件控件)
> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file <path>` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。
| 属性 | 说明 |
|------|------|
| `componentName` | `DDAttachment` |
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file <path>` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 |
### StarRatingField(评分控件)
@@ -328,7 +328,6 @@
| 流水号 | `SeqNumberField` | 由系统自动生成 |
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
+11 -7
View File
@@ -54,6 +54,7 @@
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
## 触发必读
@@ -63,6 +64,7 @@
- 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。
- 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。
- 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。
- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。
## Reference 索引
@@ -82,13 +84,14 @@
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` |
| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` |
| [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` |
| [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` |
| [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` |
| sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` |
| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` |
## 全局硬约束
@@ -99,12 +102,12 @@
5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn`
6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"`
7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除
9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段
10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension`
11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put`
12. **单元格图片用 `write-image`**(`range update` 不支持图片参数)
13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@`
16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update`
@@ -115,9 +118,10 @@
- **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。
- **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。
- **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。
- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。
- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。
- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。
- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。
- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
## URL 粘贴场景
@@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token <FOLDER_TOKEN> --name
dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID>
```
- 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格
- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node`
- **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格
@@ -12,7 +12,7 @@
- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。
- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。
- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。
- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。
- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。
---
@@ -66,7 +66,7 @@ Flags:
查询指定范围内的下拉列表配置信息。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
@@ -99,5 +99,5 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持
- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
@@ -5,11 +5,11 @@
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
- 导出表格 → `export`(单命令会自动等待完成,并可选下载)
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
- 需要落盘到本地 → `dws sheet export --node <NODE_ID> --output <path>`,命令自动下载 xlsx
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性
- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟
用户说"导出 CSV/存成 csv/导出这个工作表为 csv":
- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令)
@@ -38,26 +38,14 @@ Flags:
--output string 本地保存路径(可选,支持文件路径或目录)
```
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
**内部流程**:
1. 调 `submit_export_job` 获取 `jobId`
2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
**内置轮询策略(CLI 内实现,无需关心)**:
- 第 1~5 次:每次间隔 2 秒
- 第 6~10 次:每次间隔 5 秒
- 第 11~20 次:每次间隔 10 秒
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
**失败处理**:
- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
@@ -111,7 +99,7 @@ dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --allow-truncated --
```bash
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl)
dws sheet export --node <NODE_ID> --format json
# 传入 URL 也可:
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
@@ -122,7 +110,7 @@ dws sheet export --node <NODE_ID> --output ./report.xlsx
# 场景 C:下载到目录,自动按链接推断文件名
dws sheet export --node <NODE_ID> --output ./
# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
```
@@ -130,18 +118,18 @@ dws sheet export --node <NODE_ID> --output ./
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 |
| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 |
| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: <path>`(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 |
## 注意事项
- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性
- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export`
- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated`
@@ -173,7 +173,7 @@ Flags:
- `filter update` 前置:工作表必须已创建筛选
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
- ★ **筛选操作规范**(参照飞书 core-operations):
- ★ **筛选操作规范**:
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
@@ -6,7 +6,7 @@
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试
- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
@@ -228,8 +228,8 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
@@ -191,7 +191,7 @@ Flags:
- 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500`
- 必须包含表头行(通常从第 1 行开始)
- sheet 名称用单引号包裹(含空格或特殊字符时必须)
- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构
- `source` 直接使用上述 A1 表示法,不要改写成其他结构
## 高级功能示例
@@ -210,7 +210,7 @@ dws sheet pivot-table create --node <NODE_ID> \
}'
```
> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。
> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。
## 核心工作流
@@ -54,7 +54,7 @@ Flags:
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完
- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现
- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围
- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围
- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
@@ -161,7 +161,7 @@ Flags:
| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 |
| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 |
| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` |
| `checkbox` | `checked: boolean` | 当前勾选状态 |
SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。
@@ -270,6 +270,7 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --f
- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读
- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表
@@ -0,0 +1,306 @@
# 工作簿 revision 与 changeset
## 何时使用
当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。
- 当前 revision → `dws sheet revision-get`
- revision 区间内的变更 → `dws sheet changeset-get`
- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md)
- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令
两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。
revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。
当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。
## 获取当前 revision
```bash
dws sheet revision-get --node <NODE_ID_OR_URL> --format json
```
成功时从统一输出的 `data.revision` 读取当前 revision:
```json
{
"ok": true,
"outcome": "success",
"data": {
"success": true,
"logId": "trace-id",
"revision": 142
}
}
```
- `revision=0` 是合法的空工作簿基线。
- revision 不是工作表数量,也不是历史版本列表序号。
- `logId` 仅用于失败反馈和问题排查,不是 revision。
## 获取 revision 区间 changeset
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--end-revision 140 \
--format json
```
也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision:
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--format json
```
参数规则:
- `--start-revision` 必填,必须为非负整数;`0` 合法。
- `--end-revision` 可选;提供时必须大于或等于 start。
- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。
- `startRevision == endRevision` 合法,返回空 `changesets`。
- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。
- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。
- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。
成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。
## 先看区间摘要
`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响:
| 字段 | 含义 |
|------|------|
| `changeCount` | `changes[]` 中变更对象的总数 |
| `completeChangeCount` | 详情完整的变更数量 |
| `partialChangeCount` | 只返回部分可信详情的变更数量 |
| `unsupportedChangeCount` | 当前无法解释的变更数量 |
| `containsStateReset` | 区间内是否发生整表状态替换 |
| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 |
| `affectedSheets` | 受影响工作表及其 A1 范围摘要 |
`affectedSheets[]` 常用字段:
| 字段 | 含义 |
|------|------|
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 |
| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` |
摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。
## 读取 `changesets[]`
每个 changeset 对应一个 revision:
| 字段 | 含义 | 使用规则 |
|------|------|----------|
| `revision` | 该事件对应的 revision | 按升序解释 |
| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 |
| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 |
| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 |
| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 |
| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 |
事件类型:
- `EDIT`:一次普通编辑提交产生的效果。
- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。
- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。
changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。
## 读取 `reset`
| 字段 | 含义 |
|------|------|
| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` |
| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 |
| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 |
当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。
回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。
## 读取 `changes[]`
每个 change 都按以下顺序读取:
1. `type`:发生了哪类变更。
2. `targets`:变更作用在哪个工作簿、工作表或范围。
3. `detailsStatus`:详情是否完整。
4. `details`:本次变更的可用详情。
5. `omissions`:哪些详情未能提供。
公共字段:
| 字段 | 含义 |
|------|------|
| `type` | 语义变更类型,见下方字典 |
| `targets` | 工作簿、工作表或 A1 范围定位数组 |
| `details` | 与 `type` 对应的变更详情;无详情时为空对象 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` |
| `omissions` | 未提供详情的原因;没有省略时为空数组 |
`detailsStatus` 的处理规则:
- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。
- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。
- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。
`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。
## 读取 `targets[]`
| 字段 | 含义 |
|------|------|
| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` |
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称 |
| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 |
| `a1Range` | 1-based A1 范围 |
| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 |
- `SOURCE` 是读取或复制来源,不表示该位置被修改。
- `DESTINATION` 是写入目标,应算作受影响位置。
- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。
- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。
## 变更类型字典
### 行列与工作表
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` |
| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` |
| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` |
| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` |
| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 |
| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` |
| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` |
`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。
### 单元格与范围
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` |
| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` |
| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` |
| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` |
| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` |
| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` |
| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` |
| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` |
| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` |
| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` |
| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` |
| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` |
粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。
`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。
`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。
### 分组、数据验证和结构对象
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` |
| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` |
| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` |
| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` |
| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` |
| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` |
| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` |
扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。
`dataValidation` 常用字段:
| 字段 | 含义 |
|------|------|
| `type` | 验证规则类型 |
| `sourceType` | 下拉来源:`inline` 或 `sourceRange` |
| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` |
| `sourceRange` | 已确认的来源工作表与 A1 范围 |
| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` |
| `sourceRangeExpression` | 可读的来源表达式 |
| `enableMultiSelect` | 是否允许多选 |
| `criteria` | 非下拉规则的运算符和条件值 |
| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 |
只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。
### 工作簿设置与其他变更
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` |
| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` |
| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` |
`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。
## 常用详情值
### 单元格内容
`cell` 常见字段:
- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。
- `formula`:本次写入的可读公式。
- `formulaCleared=true`:本次清除了公式。
- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。
- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。
### 清除、null 与字段缺失
- 对象整体为 `{ "cleared": true }`:本次清除了该对象。
- `formulaCleared=true`:本次清除了公式。
- `styleCleared=true`:本次清除了该相对位置的样式。
- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。
- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。
不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。
## Agent 解读顺序
1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。
2. 直接遍历 `data.changesets`,不要把它当作字符串。
3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。
4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。
5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。
6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。
7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。
8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。
9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。
10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。
## 最终状态回读
changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。
- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。
- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。
- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。
- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。
- `STATE_RESET`:重新读取工作簿当前状态。
- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。
## 常见失败处理
- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。
- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。
- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。
- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。
- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。
@@ -99,7 +99,7 @@ Flags:
- `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000)
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等
- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并
- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并
### 批量设置单元格样式(服务端原子事务)
```
@@ -161,7 +161,7 @@ Flags:
- 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发**
- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用
- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量)
- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域
- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域
### 合并单元格
```
@@ -304,7 +304,7 @@ dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --m
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式
- `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式
- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
@@ -5,10 +5,10 @@
管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。
- 手动保存当前表格为一个版本快照 → `version save`
- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`)
- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认)
- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段
- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认)
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。
## 命令详细参考
@@ -38,32 +38,47 @@ Flags:
--limit int 返回版本数量上限 (可选)
--cursor string 分页游标 (可选,游标分页)
```
返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。
返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。
### 回滚表格到指定版本
```
Usage:
dws sheet version revert [flags]
Example:
dws sheet version revert --node SHEET_ID --version 3 --yes
dws sheet version revert --node SHEET_ID --version 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--version int 目标版本号 (必填,从 version list 获取)
--version int 目标历史版本或已确认 revision (必填,通常从 version list 获取)
```
把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。
把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。
`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。
### 精确 revision 回滚
只有用户明确要求恢复到某个 revision 时才走此流程:
1. 用 `revision-get` 记录回滚前的当前 revision。
2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。
3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。
4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。
5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。
6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 |
| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 |
| `version save` | 版本快照结果 | 确认已生成快照 |
| `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 |
## 注意事项
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes`
- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号
- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认
- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision
- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试
- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md)
- 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成"
@@ -70,12 +70,12 @@ Flags:
--styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配)
```
建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。
本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。
**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**):
- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。
- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如
- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。
- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如
`{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`;
`name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。
- 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功)
@@ -104,7 +104,7 @@ Flags:
**行为要点**:
- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档
- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据
- 命令会等待新建表格可写后再写入数据,并通过回读确认结果
- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写
- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档
+31 -4
View File
@@ -31,13 +31,14 @@ Flags:
--instance-id string 审批实例 ID (必填)
```
### 审批附件授权与下载
### 审批附件授权、上传与下载
先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -82,6 +83,21 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
#### 上传本地文件为审批附件
```
Usage:
dws oa approval attachment upload [flags]
Example:
dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
--file string 本地文件路径 (必填)
--file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。
@@ -295,7 +311,7 @@ Flags:
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file <path>` 获取字段再组装为 value 提交,不属于不支持类型。
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。
- **严禁在未得到用户确认前直接执行真实提单。**
@@ -414,7 +430,7 @@ Flags:
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file <path>` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 |
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
@@ -429,9 +445,19 @@ Flags:
- `CalculateField`(计算公式)— 由系统自动计算
- `SeqNumberField`(流水号)— 由系统自动生成
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file <path>` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例:
>
> ```bash
> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType
> dws oa approval attachment upload --file ./a.pdf
> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes
> dws oa approval create-instance --process-code PROC-xxx \
> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}'
> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes
> ```
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
@@ -748,6 +774,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
@@ -239,14 +239,14 @@
### DDAttachment(附件控件)
> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file <path>` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。
| 属性 | 说明 |
|------|------|
| `componentName` | `DDAttachment` |
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file <path>` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 |
### StarRatingField(评分控件)
@@ -328,7 +328,6 @@
| 流水号 | `SeqNumberField` | 由系统自动生成 |
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
+11 -7
View File
@@ -54,6 +54,7 @@
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
## 触发必读
@@ -63,6 +64,7 @@
- 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。
- 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。
- 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。
- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。
## Reference 索引
@@ -82,13 +84,14 @@
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` |
| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` |
| [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` |
| [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` |
| [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` |
| sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` |
| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` |
## 全局硬约束
@@ -99,12 +102,12 @@
5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn`
6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"`
7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除
9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段
10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension`
11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put`
12. **单元格图片用 `write-image`**(`range update` 不支持图片参数)
13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@`
16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update`
@@ -115,9 +118,10 @@
- **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。
- **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。
- **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。
- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。
- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。
- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。
- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。
- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
## URL 粘贴场景
@@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token <FOLDER_TOKEN> --name
dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID>
```
- 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格
- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node`
- **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格
@@ -12,7 +12,7 @@
- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。
- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。
- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。
- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。
- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。
---
@@ -66,7 +66,7 @@ Flags:
查询指定范围内的下拉列表配置信息。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
@@ -99,5 +99,5 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持
- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
@@ -5,11 +5,11 @@
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
- 导出表格 → `export`(单命令会自动等待完成,并可选下载)
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
- 需要落盘到本地 → `dws sheet export --node <NODE_ID> --output <path>`,命令自动下载 xlsx
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性
- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟
用户说"导出 CSV/存成 csv/导出这个工作表为 csv":
- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令)
@@ -38,26 +38,14 @@ Flags:
--output string 本地保存路径(可选,支持文件路径或目录)
```
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
**内部流程**:
1. 调 `submit_export_job` 获取 `jobId`
2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
**内置轮询策略(CLI 内实现,无需关心)**:
- 第 1~5 次:每次间隔 2 秒
- 第 6~10 次:每次间隔 5 秒
- 第 11~20 次:每次间隔 10 秒
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
**失败处理**:
- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
@@ -111,7 +99,7 @@ dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --allow-truncated --
```bash
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl)
dws sheet export --node <NODE_ID> --format json
# 传入 URL 也可:
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
@@ -122,7 +110,7 @@ dws sheet export --node <NODE_ID> --output ./report.xlsx
# 场景 C:下载到目录,自动按链接推断文件名
dws sheet export --node <NODE_ID> --output ./
# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
```
@@ -130,18 +118,18 @@ dws sheet export --node <NODE_ID> --output ./
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 |
| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 |
| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: <path>`(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 |
## 注意事项
- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性
- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export`
- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated`
@@ -173,7 +173,7 @@ Flags:
- `filter update` 前置:工作表必须已创建筛选
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
- ★ **筛选操作规范**(参照飞书 core-operations):
- ★ **筛选操作规范**:
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
@@ -6,7 +6,7 @@
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试
- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
@@ -228,8 +228,8 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
@@ -191,7 +191,7 @@ Flags:
- 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500`
- 必须包含表头行(通常从第 1 行开始)
- sheet 名称用单引号包裹(含空格或特殊字符时必须)
- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构
- `source` 直接使用上述 A1 表示法,不要改写成其他结构
## 高级功能示例
@@ -210,7 +210,7 @@ dws sheet pivot-table create --node <NODE_ID> \
}'
```
> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。
> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。
## 核心工作流
@@ -54,7 +54,7 @@ Flags:
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完
- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现
- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围
- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围
- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
@@ -161,7 +161,7 @@ Flags:
| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 |
| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 |
| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` |
| `checkbox` | `checked: boolean` | 当前勾选状态 |
SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。
@@ -270,6 +270,7 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --f
- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读
- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表
@@ -0,0 +1,306 @@
# 工作簿 revision 与 changeset
## 何时使用
当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。
- 当前 revision → `dws sheet revision-get`
- revision 区间内的变更 → `dws sheet changeset-get`
- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md)
- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令
两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。
revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。
当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。
## 获取当前 revision
```bash
dws sheet revision-get --node <NODE_ID_OR_URL> --format json
```
成功时从统一输出的 `data.revision` 读取当前 revision:
```json
{
"ok": true,
"outcome": "success",
"data": {
"success": true,
"logId": "trace-id",
"revision": 142
}
}
```
- `revision=0` 是合法的空工作簿基线。
- revision 不是工作表数量,也不是历史版本列表序号。
- `logId` 仅用于失败反馈和问题排查,不是 revision。
## 获取 revision 区间 changeset
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--end-revision 140 \
--format json
```
也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision:
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--format json
```
参数规则:
- `--start-revision` 必填,必须为非负整数;`0` 合法。
- `--end-revision` 可选;提供时必须大于或等于 start。
- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。
- `startRevision == endRevision` 合法,返回空 `changesets`。
- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。
- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。
- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。
成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。
## 先看区间摘要
`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响:
| 字段 | 含义 |
|------|------|
| `changeCount` | `changes[]` 中变更对象的总数 |
| `completeChangeCount` | 详情完整的变更数量 |
| `partialChangeCount` | 只返回部分可信详情的变更数量 |
| `unsupportedChangeCount` | 当前无法解释的变更数量 |
| `containsStateReset` | 区间内是否发生整表状态替换 |
| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 |
| `affectedSheets` | 受影响工作表及其 A1 范围摘要 |
`affectedSheets[]` 常用字段:
| 字段 | 含义 |
|------|------|
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 |
| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` |
摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。
## 读取 `changesets[]`
每个 changeset 对应一个 revision:
| 字段 | 含义 | 使用规则 |
|------|------|----------|
| `revision` | 该事件对应的 revision | 按升序解释 |
| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 |
| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 |
| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 |
| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 |
| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 |
事件类型:
- `EDIT`:一次普通编辑提交产生的效果。
- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。
- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。
changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。
## 读取 `reset`
| 字段 | 含义 |
|------|------|
| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` |
| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 |
| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 |
当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。
回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。
## 读取 `changes[]`
每个 change 都按以下顺序读取:
1. `type`:发生了哪类变更。
2. `targets`:变更作用在哪个工作簿、工作表或范围。
3. `detailsStatus`:详情是否完整。
4. `details`:本次变更的可用详情。
5. `omissions`:哪些详情未能提供。
公共字段:
| 字段 | 含义 |
|------|------|
| `type` | 语义变更类型,见下方字典 |
| `targets` | 工作簿、工作表或 A1 范围定位数组 |
| `details` | 与 `type` 对应的变更详情;无详情时为空对象 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` |
| `omissions` | 未提供详情的原因;没有省略时为空数组 |
`detailsStatus` 的处理规则:
- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。
- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。
- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。
`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。
## 读取 `targets[]`
| 字段 | 含义 |
|------|------|
| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` |
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称 |
| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 |
| `a1Range` | 1-based A1 范围 |
| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 |
- `SOURCE` 是读取或复制来源,不表示该位置被修改。
- `DESTINATION` 是写入目标,应算作受影响位置。
- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。
- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。
## 变更类型字典
### 行列与工作表
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` |
| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` |
| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` |
| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` |
| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 |
| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` |
| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` |
`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。
### 单元格与范围
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` |
| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` |
| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` |
| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` |
| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` |
| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` |
| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` |
| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` |
| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` |
| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` |
| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` |
| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` |
粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。
`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。
`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。
### 分组、数据验证和结构对象
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` |
| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` |
| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` |
| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` |
| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` |
| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` |
| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` |
扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。
`dataValidation` 常用字段:
| 字段 | 含义 |
|------|------|
| `type` | 验证规则类型 |
| `sourceType` | 下拉来源:`inline` 或 `sourceRange` |
| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` |
| `sourceRange` | 已确认的来源工作表与 A1 范围 |
| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` |
| `sourceRangeExpression` | 可读的来源表达式 |
| `enableMultiSelect` | 是否允许多选 |
| `criteria` | 非下拉规则的运算符和条件值 |
| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 |
只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。
### 工作簿设置与其他变更
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` |
| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` |
| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` |
`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。
## 常用详情值
### 单元格内容
`cell` 常见字段:
- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。
- `formula`:本次写入的可读公式。
- `formulaCleared=true`:本次清除了公式。
- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。
- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。
### 清除、null 与字段缺失
- 对象整体为 `{ "cleared": true }`:本次清除了该对象。
- `formulaCleared=true`:本次清除了公式。
- `styleCleared=true`:本次清除了该相对位置的样式。
- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。
- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。
不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。
## Agent 解读顺序
1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。
2. 直接遍历 `data.changesets`,不要把它当作字符串。
3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。
4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。
5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。
6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。
7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。
8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。
9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。
10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。
## 最终状态回读
changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。
- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。
- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。
- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。
- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。
- `STATE_RESET`:重新读取工作簿当前状态。
- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。
## 常见失败处理
- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。
- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。
- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。
- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。
- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。
@@ -99,7 +99,7 @@ Flags:
- `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000)
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等
- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并
- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并
### 批量设置单元格样式(服务端原子事务)
```
@@ -161,7 +161,7 @@ Flags:
- 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发**
- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用
- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量)
- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域
- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域
### 合并单元格
```
@@ -304,7 +304,7 @@ dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --m
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式
- `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式
- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
@@ -5,10 +5,10 @@
管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。
- 手动保存当前表格为一个版本快照 → `version save`
- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`)
- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认)
- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段
- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认)
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。
## 命令详细参考
@@ -38,32 +38,47 @@ Flags:
--limit int 返回版本数量上限 (可选)
--cursor string 分页游标 (可选,游标分页)
```
返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。
返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。
### 回滚表格到指定版本
```
Usage:
dws sheet version revert [flags]
Example:
dws sheet version revert --node SHEET_ID --version 3 --yes
dws sheet version revert --node SHEET_ID --version 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--version int 目标版本号 (必填,从 version list 获取)
--version int 目标历史版本或已确认 revision (必填,通常从 version list 获取)
```
把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。
把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。
`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。
### 精确 revision 回滚
只有用户明确要求恢复到某个 revision 时才走此流程:
1. 用 `revision-get` 记录回滚前的当前 revision。
2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。
3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。
4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。
5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。
6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 |
| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 |
| `version save` | 版本快照结果 | 确认已生成快照 |
| `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 |
## 注意事项
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes`
- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号
- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认
- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision
- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试
- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md)
- 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成"
@@ -70,12 +70,12 @@ Flags:
--styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配)
```
建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。
本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。
**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**):
- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。
- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如
- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。
- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如
`{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`;
`name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。
- 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功)
@@ -104,7 +104,7 @@ Flags:
**行为要点**:
- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档
- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据
- 命令会等待新建表格可写后再写入数据,并通过回读确认结果
- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写
- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档
+45 -71
View File
@@ -11,11 +11,13 @@ metadata:
# 钉钉待办 Skill
## 前置条件 — 执行操作前必读
## 执行契约
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。
- 只用 `dws` 操作钉钉待办,命令统一加 `--format json`,并按结构化业务返回判断结果。
- 已知路线直接执行;仅在 leaf 参数或安全语义不确定时查精确 Schema,在 flag 不确定时查精确 Help。不要先枚举整个 Catalog,也不要连续猜命令。
- 后续 ID 必须来自本次真实返回;跨步骤可混用 shortcut 与原子命令,但只传递规范化后的稳定 ID,不能假设两类命令的完整返回结构相同。零匹配、多匹配或类型不明时停止并消歧。
- 写操作遵循最终 Runtime gate。需要确认时先说明对象、动作和影响,用户确认后才追加 `--yes`;不要把 `--yes` 写进存储示例。
- 写后必须核验。非幂等写超时、缺少稳定 ID 或读回失败时先对账,禁止盲目重放。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
@@ -44,80 +46,52 @@ metadata:
| `dws todo +update` | write | 更新待办并读回验证 |
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
## 路由优先级
| 用户说 | 命令 |
|--------|------|
| "建一条待办给张三" | `dws todo task create --title "<标题>" --executors <userId>` |
| "较高 / 高优先级待办" | `dws todo task create ... --priority 30`(10低/20普通/30较高/40紧急) |
| "紧急 / 最高优先级 / 立即处理" | `dws todo task create ... --priority 40` |
| "循环待办(每天)" | `dws todo task create ... --due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"` |
| "批量建待办" | 按 SOP-4 逐条创建、收集 `taskId` 并批量回读 |
| "今天 / 本周未完成待办" | `python scripts/todo_daily_summary.py [today\|tomorrow\|week]` |
| "逾期待办" | `python scripts/todo_overdue_check.py` |
| "标记完成 / 重开" | `dws todo task done --task-id <taskId> --status true\|false` |
| "修改标题/截止时间/优先级" | `dws todo task update --task-id <taskId> ...` |
| "删除待办" | `dws todo task delete --task-id <taskId>`(需用户确认) |
不要按整段请求强制选择同一类命令;先拆成有序步骤,再逐步选择最窄且完整覆盖该步骤的入口:
## 标准 SOP(必遵流程)
1. **确定创建入口**:按姓名指派用 `+assign` / `+assign-multi`。给自己创建且后续只有定位、回读和清理时用 `+remind`;已有 `userId` 的同类短流程用 `+create`。后续若有筛选、资源写入或多个对象,则用原子 `task create`。
2. **后续逐步路由**:详情、完成、重开、更新、搜索、评论、提醒及评论/附件/子待办列表,优先用完整覆盖该步的 `+get` / `+complete` / `+reopen` / `+update` / `+search` / `+comment` / `+reminder` / `+list-*`。无完整 shortcut 时使用原子命令。
3. **跨步骤只传稳定 ID**:shortcut 与原子命令可以共存;创建后先提取真实 `taskId`,评论、附件和标签再从各自真实返回取 ID。不要把某个入口的包装字段路径套到另一个入口。
4. **确定性批量/汇总 → 脚本**:批量创建、今天/明天/本周汇总、逾期扫描分别用 bundled scripts。
5. **未知低频能力 → 精确 Reference/Help**:只读当前操作的小节,不要枚举后试错。
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 taskId。每条命令必须带 `--format json`。创建/完成/删除后**必须**回读验证,不要凭创建返回或口头计划就结束。
## 高频路线
### SOP-1 建待办(create-todo)
| 用户意图 | 首选入口 | 边界 |
|---|---|---|
| 给自己创建,随后只定位、看详情或清理 | `dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json` | `--at` 是截止时间,不是独立提醒;若后续有列表筛选或资源写入则改用原子创建 |
| 按姓名给一人/多人指派一条待办 | `+assign` / `+assign-multi` | 任一姓名不唯一就停止,不能猜 `userId` |
| 已有 `userId`,只创建、回读和清理一条待办 | `dws todo +create --title "<标题>" --executors <USER_ID> ... --format json` | 结果必须含稳定 `taskId` 且读回一致 |
| 创建后还要筛选或修改资源 | `dws todo task create ... --format json` | 从 `result.taskId` 进入后续步骤;后续读取仍可选 `+get` 等 shortcut |
| 按状态、优先级、角色、日期或页码枚举 | `dws todo task list ... --format json` | `--status false/true`;`hasMore=true` 继续翻页 |
| 当前组织我的待办 / 与我相关的全部待办 | `+get-my-tasks` / `+get-related-tasks` | 后者是创建人、执行人、参与人并集 |
| 按标题关键词定位 / 已知 ID 查详情 | `+search --query ...` / `+get --task-id ...` | 零个或多个候选时停止消歧 |
| 已知 ID 完成、重开、更新 | `+complete` / `+reopen` / `+update` | shortcut 会做状态检查或读回核验 |
| 今天到期 / 逾期 | `+due-today` / `+overdue` | 空集合也是成功结果 |
| 设置或清除独立提醒 | `+reminder` | 上游无提醒查询接口,只能报告写回执,不能声称读回 |
| 创建、改名、列出或删除待办标签 | `tag create` / `tag update` / `tag list` / `tag delete` | “待办标签”属于 Todo,不是通讯录标签、Git tag 或其他产品标签 |
**触发**:建待办/任务提醒/指派任务/TODO。
## 组合任务闭环
1. **解析执行者(必须)**:指定姓名 → `dws aisearch person --query "<姓名>" --dimension name --format json` 取 `userId`;未指定 → `dws contact user get-self --format json` 取当前用户 `userId`;多人逐个搜索后英文逗号拼接。
2. **执行(必须)**:`dws todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40> --format json`;有截止时间加 `--due "<ISO>"`;循环待办加 `--due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"`。
3. **验证(必须)**:从返回取 `taskId`/`todoTaskId`,立即 `dws todo task get --task-id <taskId> --format json` 回读。
1. 执行前列出用户要求的全部资源动作及顺序,并为每一步单独选 shortcut 或原子命令;同一链路使用同一个 profile。
2. 原子创建必须先取得执行人:未指定执行人用 `dws contact me --format json`;指定姓名用 `dws aisearch person --query "<姓名>" --dimension name --format json` 并唯一匹配。
3. 原子创建从 `result.taskId` 取 ID;创建 shortcut 从其成功结果的 `taskId` 取 ID。后续评论、附件和标签编号分别来自 `+comment`/`comment list`、`task list-attachment`、`tag create/list` 的真实返回;不要跨入口猜字段层级。
4. 每次写后使用对应 read/list 核验;提醒例外,只保留终端写回执。最后仅清理本次创建且已经记录 ID 的对象。
**禁止**:跳过执行者解析直接传姓名、用 `task detail` 取详情(正确是 `task get`)、创建后不回读。
## 关键约束
### SOP-2 查询待办(query-todo)
- 公开命令统一使用 `--task-id`;不要在新命令中使用隐藏兼容别名 `--id` / `--ids`。
- 优先级:低=10、普通=20、较高/高/重要=30、紧急/最高/P0=40。
- `--due` 与 `+remind --at` 表示 deadline;独立 reminder 用 `+reminder` 或原子 `task add-reminder`。
- `task list` 用 `--status`,不要写 `--done`;详情命令是 `task get`,不存在 `task detail`。
- “待办标签”始终使用 `dws todo tag ...`,绝不能解释为 Git tag、文档标签或其他产品标签。
- 本地文件作为待办附件时使用 `task add-attachment --file <绝对路径>`;先确认待办存在,不得用上传动作试探权限。
- 会后行动项先走 `dingtalk-minutes` 取真实内容;OA 审批走 `dingtalk-misc`;时间块和会议走 `dingtalk-calendar`。
**触发**:查待办/今天本周待办/未完成/已完成。
## 按需参考
1. **执行(必须)**:`dws todo task list --status false|true --format json`(`false`=未完成、`true`=已完成、不传=全部);`hasMore=true` 必须翻页。
2. **摘要脚本(必须)**:今天/本周未完成 → `python scripts/todo_daily_summary.py today|tomorrow|week`;逾期 → `python scripts/todo_overdue_check.py`。
3. **详情(必须)**:`dws todo task get --task-id <taskId> --format json`;按主题筛选先 `task list` 再按标题过滤,**禁止**编造主题查询 flag。
**禁止**:写 `--done true`(用 `--status true`)、编造主题筛选参数。
### SOP-3 完成 / 重开 / 改 / 删(mutate-todo)
**触发**:标记完成/重开/改标题截止优先级/删待办。
1. **执行(必须)**:完成/重开 `dws todo task done --task-id <taskId> --status true|false --format json`;修改 `dws todo task update --task-id <taskId> ...`;删除 `dws todo task delete --task-id <taskId>`(**必须**先与用户确认)。
2. **验证(必须)**:`task done`/`update` 后用 `task get` 或对应 `task list --status ...` 回读确认;`delete` 后用 `task get` 确认已不存在或列表已移除。
**禁止**:未确认就删除、用 `update --done`(首选 `task done --status`)、改动后不回读。
### SOP-4 批量建待办(batch-create)
**触发**:批量建待办/一次建多条。
1. **解析(必须)**:执行者姓名先批量解析成真实 `userId`;单批最多 30 条。
2. **执行(必须)**:对每条待办执行 `dws todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40> [--due "<ISO>"] --format json`,逐条收集返回的 `taskId`/`todoTaskId`。可并行执行,但不得丢失“输入条目 → taskId”对应关系。
3. **验证(必须)**:对全部新建 `taskId` 执行 `dws todo task get --task-id <taskId> --format json` 回读;多 ID 按共享并行规则处理,全部成功后才能报告批量创建完成。
**禁止**:只统计创建命令退出码、不保留 taskId、创建后不回读、在执行者位置传姓名。
## 参数硬约束
- 任务详情只用 `dws todo task get --task-id <taskId>`;不要写 `task detail`。
- 完成状态首选 `dws todo task done --task-id <taskId> --status true|false`;若用 `update`,也必须是 `--task-id` + `--done true|false`。
- 查询列表完成状态用 `dws todo task list --status false|true --format json`。不要写 `--done true` 作为可见参数,虽然兼容但不作为推荐写法。
- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。
- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。
- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。
- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。
- 所有 dws 命令带 `--format json`。
## 跨产品协作
- 执行人是人名 → 先用 `dingtalk-aisearch` 拿 `userId`
- 会后从听记自动建待办 → 切到 `dingtalk-minutes`
- 项目进度汇总写文档 → 切到 `dingtalk-doc`
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
- [单步与短流程](references/lite-recipes.md)
- [组合生命周期与动态 ID 传递](references/02-task.md)
- [局部意图消歧](references/intent-guide.md)
- [完整原子命令参考](references/todo.md)
@@ -1,19 +1,61 @@
# 任务管理
# Todo 组合生命周期
> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`todo-query-ops`。其中 `todo-query-ops` 统一覆盖 list/get/complete/reopen/topic-filter。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](./todo.md)。
当一个请求包含多个资源动作并需要传递 `taskId`、`commentId`、`attachmentId` 或 `tagCode` 时使用本文件。组合链路可以逐步混用 shortcut 与原子命令,但只能传递已经规范化的稳定 ID,不能把一种入口的完整返回结构当成另一种入口的结构。
## Recipe 速查(非 SKILL lite)
## 通用骨架
| Recipe | 步骤(命令均须 `--format json`,下略) |
|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读标题、执行者、优先级和截止时间 |
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --priority <10/20/30/40> --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](./todo.md))→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读循环规则和任务字段 |
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
1. 先列出完整动作序列和需要传递的 ID,不要边执行边发现路线。
2. 未指定执行人时运行 `dws contact me --format json`;指定姓名时运行 `dws aisearch person --query "<姓名>" --dimension name --format json`,唯一匹配后取 `userId`。
3. 选择创建入口:后续只有搜索、详情回读和清理时可用 `+remind` / `+create`;若要列表筛选、资源写入、创建子资源或一次创建多个对象,使用原子创建:
## Full / 组合(固定路线)
```bash
dws todo task create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] [--recurrence "<规则>"] --format json
```
| Recipe | 行动指南(固定路线) |
|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| generate-progress-report | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
| batch-create-todo | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --query "<姓名>" --dimension name` → 取真实 `userId`;同名时先消歧<br>3. 逐条执行 `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>`,从每次响应收集真实 `todoTaskId`;单批超 30 条须用户确认<br>4. 对全部 `todoTaskId` 并行执行 `todo task get --task-id <todoTaskId>`,逐项核对标题、执行者、优先级和截止时间;不能只以退出码或创建响应作为成功证据 |
| assign-and-notify | 1. `aisearch person --query "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>` → 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读任务,确认无误后再通知<br>4. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --conversation-id <openConversationId> --content "<通知内容>"` |
4. 原子创建从成功响应的 `result.taskId` 取 ID;创建 shortcut 从其成功结果的 `taskId` 取 ID。需要详情时优先 `dws todo +get --task-id <TASK_ID> --format json`。
5. 按下表执行后续动作;每一步都复用真实 ID。表中原子命令是保底路径;若对应 `+get`、`+search`、`+complete`、`+reopen`、`+update`、`+comment`、`+reminder` 或 `+list-*` 完整覆盖当前步骤,则优先使用 shortcut 自带的校验。
6. 只清理本次创建且已记录 ID 的对象;删除类操作先走 Runtime 确认门。删除后 `task get` 不存在或列表移除才算清理完成。
## 原子路线表
| 意图 | 写命令 | 核验 / ID 来源 |
|---|---|---|
| 按状态/优先级/角色/日期查询 | `task list --status ... --priority ... --role-types ... --plan-finish-date-start ... --plan-finish-date-end ...` | 遍历 `result.todoCards[]`;`hasMore=true` 时递增 `--page` |
| 更新标题/优先级/截止时间 | `task update --task-id <TASK_ID> ...` | `task get --task-id <TASK_ID>` 逐字段核验 |
| 完成 / 重开 | `task done --task-id <TASK_ID> --status true|false` | `task get` 或对应状态的 `task list` |
| 创建子待办 | `task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID>` | 取响应 `result.taskId`;`task list-sub --task-id <PARENT_ID>` |
| 增删执行人 | `task add-executor --task-id <TASK_ID> --executors <USER_ID>` / `task remove-executor --task-id <TASK_ID> --executors <USER_ID>` | `task get`;只移除本次明确添加的人 |
| 增删参与人 | `task add-participant --task-id <TASK_ID> --participants <USER_ID>` / `task remove-participant --task-id <TASK_ID> --participants <USER_ID>` | `task get`;执行人与参与人不可混用 |
| 添加评论 | `comment add --task-id <TASK_ID> --content "<内容>"` | 从 `comment list` 的 `result.comments[].id` 取真实评论 ID 并核对内容 |
| 删除评论 | `comment delete --task-id <TASK_ID> --comment-id <COMMENT_ID>` | 再次 `comment list` 确认目标不存在 |
| 上传附件 | `task add-attachment --task-id <TASK_ID> --file <绝对路径>` | `task list-attachment` 取真实 `attachmentId` 并核对文件名 |
| 移除附件 | `task remove-attachment --task-id <TASK_ID> --attachment-id <ATTACHMENT_ID>` | 再次 `task list-attachment` |
| 添加截止前提醒 | `task add-reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30` | 只有终端写回执;待办必须已有截止时间 |
| 添加独立提醒 | `task add-reminder --task-id <TASK_ID> --base-time customTime --reminder-time-stamp "<提醒ISO>"` | 只有终端写回执 |
| 替换/清空提醒 | `task reset-reminder --task-id <TASK_ID> [--reminder-rules '<JSON数组>']` | 不传规则即清空;不能声称已读回规则 |
| 创建/列出标签 | `tag create --name "<名称>"` / `tag list` | 创建前后比较 `result.userTags[]`,只接受唯一新增的 `code` |
| 关联标签 | `tag add --task-id <TASK_ID> --tag-codes <TAG_CODE_1>[,<TAG_CODE_2>]` | `task get` 或写回执;最多两个标签 |
| 改名标签 | `tag update --user-tags '[{"code":"<TAG_CODE>","name":"<新名称>"}]'` | `tag list` 核对同一 `code` 的名称 |
| 删除标签定义 | `tag delete --tag-codes <TAG_CODE>` | `tag list` 确认标签定义不存在 |
| 删除待办 | `task delete --task-id <TASK_ID>` | `task get` 不存在或对应列表已移除 |
所有表中命令都加前缀 `dws todo` 和后缀 `--format json`。
## 动态 ID 账本
| ID | 只允许来自 | 禁止来源 |
|---|---|---|
| `taskId` | 原子 `task create/create-sub/get/list` 或 Todo shortcut 的成功业务返回 | 标题、URL、展示序号、其他 case |
| `commentId` | 同一 `taskId` 的 `+comment` 成功结果或 `comment list` 的 `result.comments[].id` | 评论文本或猜测 |
| `attachmentId` | 同一 `taskId` 的 `task list-attachment` 返回 `attachments[].attachmentId` | 文件名或本地路径 |
| `tagCode` | `todo tag create/list` 返回 `result.userTags[].code` | 标签名或 Git tag |
| `userId` | `contact me` 或 `aisearch person` 的唯一匹配 | 姓名、手机号片段、其他 profile |
“待办标签”只能调用 `dws todo tag ...`;禁止运行 `git tag`。本地待办附件只能调用 `dws todo task add-attachment`,不要改走 Drive。
## 失败与恢复
- `unknown command/flag`:查该 leaf 的 `--help` 后最多修正一次,不要轮询相似命令。
- 创建、评论等非幂等写超时或返回不明:保留“可能已提交”,先按标题/父 ID 查询对账,不自动重试。
- 提醒接口没有查询能力:成功时只报告服务端接受写入;失败时保留原错误。
- 任一步失败后仍要按账本清理已创建的临时对象;未取得稳定 ID 的对象不得猜 ID 清理。
@@ -1,11 +1,15 @@
# todo 局部意图消歧
# Todo 局部意图消歧
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
| 用户说 | 应该用 | 不要用 | 边界 |
|---|---|---|---|
| “帮我记一下明天要做的事” | `todo +remind` | `doc` | 这是个人待办;`--at` 写截止时间 |
| “给自己留一个明天下午的时间块” | `calendar event create` | `todo` | 时间块属于日历事件 |
| “明早 9 点提醒我提交周报” | 先创建待办,再用 `todo +reminder --base-time customTime --at ...` | 把 `+remind --at` 当提醒 | 独立提醒与截止时间是两个字段 |
| “截止前 30 分钟提醒” | `todo +reminder --base-time dueTime --due-date-offset -30` | `calendar` | 待办必须先有截止时间 |
| “每天重复提醒我” | `todo task create --due ... --recurrence ...`,必要时再加 reminder | 只写 reminder | recurrence 管重复待办;reminder 管单条待办提醒 |
| “创建/修改/删除待办标签” | `dws todo tag ...` | `git tag`、文档标签 | 标签名不是 `tagCode`,后续使用真实返回 |
| “提交日报/周报” | `dingtalk-misc` | `todo` | 日志产品不是个人待办 |
| “审批这个申请” | `dingtalk-misc` | `todo` | OA 审批任务与个人待办不同 |
| “把会议行动项建成待办” | 先用 `dingtalk-minutes` 取真实行动项,再进入 Todo | 凭标题猜行动项 | 来源证据归听记,任务对象归 Todo |
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|---|---|---|---|---|
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` | 个人待办提醒,非文档内容 |
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
| "明早 9 点提醒我提交周报" | 创建个人待办,但需先声明 reminder 边界 | `todo` | `calendar` | todo 当前只支持 dueTime 截止时间,不支持独立精确 reminder |
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
| "把最近几次关于XX的会议汇总成报告" | 按主题汇总多次听记 | #5 generate-topic-report | #7 meeting-followup | #7 是单次会议听记跟进;多次会议按主题汇总属于工作汇报 |
提醒写入目前没有对应查询接口,成功响应只能证明服务端接受了写请求,不能声称已经读回核验规则。
@@ -1,151 +1,57 @@
# todo Lite Recipe
# Todo 单步与短流程
本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。
用于单步 Todo 意图,也用于组合请求中被 shortcut 完整覆盖的独立步骤。涉及动态子资源 ID 或原子特有操作时,同时读 [02-task.md](02-task.md)。
## #2 任务管理
## 创建一条待办
### create-todo
1. 确定执行者:指定姓名 → `aisearch person --query "<姓名>" --dimension name` → `userId`;未指定 → `contact user get-self` → `userId`;多人 → 逐个搜索逗号拼接。
2. 创建:`todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <优先级>`(可选 `--due "<截止ISO>"`)→ `todoTaskId`
### todo-query-ops
- 查询:`todo task list [--status false|true]`(不传=全部)
- 详情:`todo task get --task-id <id>`
- 完成/重开:`todo task done --task-id <id> --status <true|false>`
- 按主题筛选:list 后按标题关键词过滤
## #7 听记与会后
> 产品命令完整参考见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。full recipe 见 [07-minutes.md](../../dingtalk-minutes/references/07-minutes.md)。
### minutes-query(查询与获取)
> **scope 选择铁律(P2 真实 badcase)**:`list` 后的 scope 决定查询范围,最高频误判是把"我能访问的所有听记"错选成 `mine`:
> - `mine` = **仅我自己创建/发起**的听记(范围最窄)
> - `shared` = **仅他人共享给我**的听记
> - `all` = **我可访问的全部**(= mine ∪ shared,范围最广)
> - **判定口诀**:query 含"访问/权限/可见/能看到/所有/我的"等覆盖范围语义 → 一律走 `all`;**仅当**明确说"我创建的/我发起的/我录的" → 才走 `mine`。**不要因为句子里有"我"字就退化成 `mine`。**
> - 错误:`我能访问的所有听记` → `list mine`(漏掉共享给我的,判定不通过)
> - 正确:`我能访问的所有听记` → `dws minutes list all --format json`
> **选对象铁律(0605 P2 EDD badcase 提炼,命令对了但选错听记 = 整任务失败)**:list/搜索拿到结果后,必须按语义精准锁定目标听记,详见 [minutes.md](../../dingtalk-minutes/references/minutes.md)「选对象铁律 S1~S6」。速记:
> - **S1 跨组织汇总**:以 list 返回的 `taskUuid + title + organizationName` 三元组为准逐条照抄,组织与听记不可张冠李戴。
> - **S2 "最近一次某类会议"**:先 `--query "<主题词>"`(如周会)过滤出该类,再在候选里取时间最新;主题匹配优先级高于时间。
> - **S3 比时长最长**:必须读 `durationMicros` 字段做数值比较,禁止凭印象/标题猜,口头结论与操作的 taskUuid 须自洽。
> - **S4 内容为空**:锁定 taskUuid 后所有 get/update 复用同一 id;某字段为空就如实说,**禁止偷偷切换到另一条听记**。
> - **S5 模糊日期匹配不到**:日期可能是"会议主题日期"而非"创建日期",按标题关键词搜,精确日期没命中就放宽 ±7 天/同主题候选请用户确认,**禁止直接报"找不到"**。搜索回退策略:① 先 `--query "<主题关键词>"` 不带日期搜 → ② 若结果过多则加 `--start/--end` 扩大到 ±7 天 → ③ 列出候选让用户确认。
> - **S6 给标题没给 id**:必须先 `list all --query "<标题关键词>"` 定位 taskUuid 再 update/get,禁止凭记忆直接填 `--id` 跳过定位。
**列表查询**(`list` 后**必须**跟 scope:`mine`/`shared`/`all`,默认补 `all`):
给自己:
```bash
# 我可访问的所有听记(默认)
dws minutes list all --format json
# 按关键词服务端搜索(严禁全量拉取后本地 grep)
dws minutes list all --query "周会" --format json
# 按时间范围筛选(ISO-8601 格式)
dws minutes list mine --start "2026-05-01T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 关键词 + 时间组合
dws minutes list all --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 限制条数
dws minutes list mine --limit 5 --format json
# 共享给我的听记
dws minutes list shared --query "ROI" --format json
dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json
```
| 参数 | 说明 |
|------|------|
| `--query "<关键词>"` | 服务端关键词搜索 |
| `--start "<ISO-8601>"` | 开始时间 |
| `--end "<ISO-8601>"` | 结束时间 |
| `--limit <N>` | 每页条数,默认 10(`--max` 为兼容别名) |
| `--cursor "<token>"` | 分页 token,首页留空(`--next-token` 为兼容别名) |
**获取详情**:
- 批量基础信息:`minutes get batch --ids <uuid1,uuid2,...>`
- 单篇摘要:`minutes get summary --id <taskUuid>`
- 转写原文(自动翻页):`minutes get transcription --id <taskUuid>`(返回 `nextToken` 时用 `--next-token <token>` 继续)
- 关键词:`minutes get keywords --id <taskUuid>`
- 待办事项:`minutes get todos --id <taskUuid>`
- 基础信息:`minutes get info --id <taskUuid>`
- 音频地址:`minutes get audio --id <taskUuid>`
> `--id`/`--uuid`/`--task-uuid` 三者等价。推荐 `--id`。
### minutes-edit(编辑与替换)
- **替换转写文字**:`minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"`
- 执行前检查特殊字符(引号/书名号/括号等),若包含先提示用户确认去除
- 替换成功后追问是否加热词:`minutes hot-word add --words "新文字"`
- **替换发言人**:先统一搜人取得 dingUid → `minutes speaker replace --id <taskUuid> --from "发言人X" --to "姓名" --target-uid <userId>`
- 查询 dingUid:`aisearch person --query "姓名" --dimension name --format json` → 取 `userId`
- 多个匹配 → 列出候选让用户选;无匹配 → 不带 `--target-uid` 执行
- **修改标题**:`minutes update title --id <taskUuid> --title "新标题"`
- **修改摘要**:`minutes update summary --id <taskUuid> --content "新内容"`
- **热词管理**:`minutes hot-word add --words "词1,词2"` / `minutes hot-word list`
- **思维导图**:`minutes mind-graph create --id <taskUuid>` → `mind-graph status --id <taskUuid>` 轮询至完成
### minutes-tag(标签/分组查询)
- 查询标签列表:`minutes tag list` → 返回用户在听记页面创建的所有标签/分组(含 tagId 和名称)
- 按标签查听记:`minutes tag query --tag-id <tagId> [--limit 20] [--cursor <token>]`
- tagId 来自 `tag list` 返回值,不可编造
- 支持分页,`--cursor` 传入上一次返回的 nextToken
**典型链路**:用户说"帮我看看'周会'标签下的听记" →
1. `dws minutes tag list --format json` → 按名称匹配找到 tagId
2. `dws minutes tag query --tag-id <tagId> --format json`
### minutes-permission(权限管理)
- 添加成员:`minutes permission add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy 4`
- 需先通过 `aisearch person --query "<姓名>" --dimension name` 获取目标 userId
- policy:0=不可见 / 1=仅查看 / 2=查看+下载 / 3=查看+下载+编辑 / 4=全部权限
- 移除成员:`minutes permission remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>`
### minutes-upload(音频上传)
按姓名指派:
```bash
# 创建上传会话
dws minutes upload create --file-name "meeting.mp3" --file-size 61565431 --format json
# 上传完成后确认
dws minutes upload complete --session-id <sid> --format json
# 取消上传
dws minutes upload cancel --session-id <sid> --format json
dws todo +assign --to "<姓名>" --task "<标题>" --format json
dws todo +assign-multi --to "<姓名1>,<姓名2>" --task "<标题>" --format json
```
### 最佳实践案例速查(详见 [minutes.md](../../dingtalk-minutes/references/minutes.md))
已经有真实 `userId`:
| 案例 | 场景 | 正确链路 |
|------|------|----------|
| 案例 1 | 听记 URL + 创建思维导图 | 提取 taskUuid → `mind-graph create` → `mind-graph status` 轮询;**禁止**走 app-development 或前端库 |
| 案例 2 | 替换文字后未引导热词 | 检查特殊字符 → `replace-text` → 追问加热词 `hot-word add` |
| 案例 3 | 查听记拉了不必要的转写 | 用户只要列表 → `list` 即可,**不要**自动拉 `get transcription` |
| 案例 4 | 拉完转写只输出时间线原文 | 拉完后追问按发言人聚类 → 引导匹配 → 调用 `speaker replace` 写回 |
| 案例 5 | 查某人说了什么不引导替换 | 推断发言人 → **用户确认** → 结构化总结 → 引导 `speaker replace` |
| 案例 6 | 通讯录+部门+转写三路印证 | Step 3 画像 + Step 4 `aisearch person` 并发 → 置信度 ≥70% → 确认 → 替换 |
| 案例 7 | grep 花名误判未参会 | **禁止**在转写文本里 grep 人名判参会;**必须**调 `aisearch person` |
| 案例 8 | 听记类 query 不走 dws | **禁止**用 session_search/browser_use/activity:search 替代 dws;模糊请求先 `list mine` |
| 案例 9 | 按标签筛选听记 | `tag list` → 按名称匹配 tagId → `tag query --tag-id <tagId>`;**禁止**编造 tagId |
```bash
dws todo +create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] --format json
```
### 听记取数深度约束(0609 点踩 case 提炼)
后续只有搜索、详情回读和清理时可保留上述创建 shortcut;若还要列表筛选、更新状态/字段、提醒、评论、附件、成员、子待办、标签或创建多个对象,创建步骤改用原子 `task create`。成功结果必须含稳定 `taskId` 并完成读回。超时、缺少 ID 或 `verified!=true` 时先用搜索/列表对账,禁止重放非幂等创建。
> 详细说明见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。
## 查询与定位
- **转写原文硬约束**:用户诉求含「聚焦原话/逐字/沟通细节/具体讨论了什么」等词时,**必须先调 `get transcription` 翻页拉全**,禁止仅凭 summary 出稿
- **数据源下钻**:听记维度**必须 `get summary`(或 `get transcription`)读正文**,严禁只取标题列表;scope 用 `all`;空时换窗重试或标注
- **听记链接解析**:聊天消息中遇到听记链接(`flash_minutes_detail`/`SHANJI`)→ 解析 `minutesId` → 调 `minutes get summary/transcription`,禁止把链接降级为关键词
- **忠实性约束**:源数据无某要素(行动项/责任人/数字)时禁止生成;统计字段基于实际取数计数,不得编造
- **多源全覆盖**:用户枚举多数据源时每个来源都必须调对应工具;瞬时错误重试;如实声明缺失来源,禁编无来源数字
```bash
dws todo +get-my-tasks --all --status false --format json
dws todo +get-related-tasks --format json
dws todo +search --query "<标题关键词>" --format json
dws todo +get --task-id <TASK_ID> --format json
```
### 间接意图识别铁律
- list 用于枚举,search 用于标题关键词,get 用于已知稳定 ID。
- 空集合是成功;零匹配或多匹配时不得自行选第一条。
- 用户明确要求状态、优先级、角色、日期或页码筛选时,使用 `todo task list` 的对应 flag,不要拉全后猜。
query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/日报/复盘/商业分析/市场感知),听记采集是**必跑前置步骤**:
## 完成、重开与更新
1. **铁律 A**:任务含"会议/讨论/沟通"信息需求 → `dws minutes list` 必跑
2. **铁律 B**:用户说"文档啥也没有" → 听记优先级更高(唯一结构化数据源)
3. **铁律 C**:多源聚合场景 → 每个被提及的数据源都必须有采集动作,听记侧 0 调用 = 严重失败
```bash
dws todo +complete --task-id <TASK_ID> --format json
dws todo +reopen --task-id <TASK_ID> --format json
dws todo +update --task-id <TASK_ID> --title "<新标题>" --format json
```
只记得标题时用 `+todo-done --task "<关键词>"`;它只在唯一命中时修改。
## 提醒、汇总与批量
- `+remind --at` 设置截止时间;独立提醒使用 `+reminder --base-time customTime --at "<提醒ISO>"`。
- 截止前提醒使用 `+reminder --base-time dueTime --due-date-offset -30`,待办必须已有截止时间。
- 今天/明天/本周汇总:`python scripts/todo_daily_summary.py today|tomorrow|week`。
- 逾期扫描:`python scripts/todo_overdue_check.py`。
- 批量创建:`python scripts/todo_batch_create.py <todos.json>`;单批最多 30 条,以逐项 ledger 为准。
+19 -24
View File
@@ -108,7 +108,6 @@ Usage:
dws todo task delete [flags]
Example:
dws todo task delete --task-id <taskId>
dws todo task delete --task-id <taskId> --yes
Flags:
--task-id string 待办任务 ID (必填)
```
@@ -146,7 +145,6 @@ Usage:
dws todo comment delete [flags]
Example:
dws todo comment delete --task-id <taskId> --comment-id <commentId>
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes
Flags:
--task-id string 待办任务 ID (必填)
--comment-id string 评论 ID (必填)
@@ -237,7 +235,6 @@ Usage:
dws todo task remove-attachment [flags]
Example:
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes
Flags:
--attachment-id string 待办附件 ID (必填)
--task-id string 待办任务 ID (必填)
@@ -314,7 +311,6 @@ Usage:
dws todo tag delete [flags]
Example:
dws todo tag delete --tag-codes code1,code2
dws todo tag delete --tag-codes code1,code2 --yes
Flags:
--tag-codes string 要删除的标签编码列表,逗号分隔 (必填)
--yes 跳过交互确认,直接执行删除
@@ -384,7 +380,7 @@ Flags:
## 核心工作流
```bash
# 1. 创建待办 — 提取 todoTaskId
# 1. 创建待办 — 提取 result.taskId
dws todo task create --title "修复线上Bug" --executors userId1,userId2 \
--priority 40 --due "2026-03-10T18:00:00+08:00" --format json
@@ -409,8 +405,8 @@ dws todo task update --task-id <taskId> --title "新标题" --priority 40 --form
# 5. 标记待办完成
dws todo task done --task-id <taskId> --status true --format json
# 6. 删除待办
dws todo task delete --task-id <taskId> --yes --format json
# 6. 删除待办(先按 Runtime gate 取得用户确认)
dws todo task delete --task-id <taskId> --format json
# 7. 给待办新增评论
dws todo comment add --task-id <taskId> --content "已开始处理" --format json
@@ -418,8 +414,8 @@ dws todo comment add --task-id <taskId> --content "已开始处理" --format jso
# 8. 查看待办评论列表
dws todo comment list --task-id <taskId> --page 1 --size 20 --format json
# 9. 删除待办评论
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes --format json
# 9. 删除待办评论(先按 Runtime gate 取得用户确认)
dws todo comment delete --task-id <taskId> --comment-id <commentId> --format json
# 10. 添加待办执行人
dws todo task add-executor --task-id <taskId> --executors userId1,userId2 --format json
@@ -445,8 +441,8 @@ dws todo task list-sub --task-id <taskId> --format json
dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --format json
# 20. 查询待办附件列表
dws todo task list-attachment --task-id <taskId> --format json
# 21. 删除待办附件
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes --format json
# 21. 删除待办附件(先按 Runtime gate 取得用户确认)
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --format json
# 22. 查询待办标签列表
dws todo tag list --format json
@@ -455,30 +451,29 @@ dws todo tag create --name "标签名" --format json
# 24. 给待办打标签
dws todo tag add --task-id <taskId> --tag-codes code1,code2 --format json
# 25. 更新待办标签
dws todo tag update --user-tags '[{"tagCode":"code1","name":"新名称"}]' --format json
# 26. 删除待办标签
dws todo tag delete --tag-codes code1,code2 --yes --format json
dws todo tag update --user-tags '[{"code":"code1","name":"新名称"}]' --format json
# 26. 删除待办标签(先按 Runtime gate 取得用户确认)
dws todo tag delete --tag-codes code1,code2 --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|---------------------------------------------|
| `task create` | `todoTaskId` | update/done/get/delete 的 --task-id |
| `task list` | `result[].id` | update/done/get/delete 的 --task-id |
| `task create` | `todoTaskId` | update/done/get/delete/comment 的 --task-id |
| `task list` | `result[].id` | update/done/get/delete/comment/add-executor/remove-executor/add-participant/remove-participant 的 --task-id |
| `task create` / `task create-sub` | `result.taskId` | update/done/get/delete/comment 的 `--task-id` |
| `task list` | `result.todoCards[].taskId` | update/done/get/delete/comment/member 操作的 `--task-id` |
| `task get` | `result.todoDetailModel.subTodos[]` | 获取子待办列表,提取子待办的 `taskId` 用于后续操作 |
| `comment list` | `result[].commentId` | `comment delete` 的 --comment-id |
| `task list-attachment` | `result[].attachmentId` | `task remove-attachment` 的 --attachment-id |
| `comment list` | `result.comments[].id` | `comment delete` 的 `--comment-id` |
| `task list-attachment` | `attachments[].attachmentId` | `task remove-attachment` 的 `--attachment-id` |
## 注意事项
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
- `--due` 与 `todo +remind --at` 都是截止时间;不要把它们解释成独立提醒
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
- 独立提醒使用 `todo +reminder --base-time customTime --at <ISO>`,或原子命令 `task add-reminder --base-time customTime --reminder-time-stamp <ISO>`
- 截止前提醒使用 `todo +reminder --base-time dueTime --due-date-offset <分钟>`,或对应原子命令;上游没有提醒查询接口,只能报告写回执
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- `task list` 的 `--priority` 支持逗号分隔多个优先级值(如 `40,30,10`),用于同时筛选多个优先级
- `task list` 的 `--role-types` 支持 `creator`/`executor`/`participant`,可在一次调用中同时传入多个角色用逗号分隔(如 `--role-types creator,executor`),无需分多次查询;不传时默认按 `executor` 查询
@@ -486,7 +481,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
- todo 是个人待办管理产品
- `task update` 可同时修改标题/优先级/截止时间/完成状态
- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同
- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认
- `task delete` 为不可逆操作;按 Runtime gate 取得用户确认后执行
- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取
- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
@@ -501,7 +496,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
- `tag add` 用于给指定待办打标签,`--task-id` 可通过 `task list` 或 `task create` 获取;`--tag-codes` 可通过 `tag list` 获取
- `tag create` 用于创建新标签,`--name` 为标签名称 (必填)
- `tag update` 用于更新已有标签信息,`--user-tags` 格式同 `tag create`
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认;传 `--yes` 可跳过交互提示,建议加 `--yes` 并与用户确认
- `tag delete` 用于删除标签定义,为不可逆操作;按 Runtime gate 取得用户确认后执行
- `tag add`(给待办打标签)与 `tag delete`(删除标签定义)作用不同:前者是关联关系,后者是删除标签本身
@@ -78,6 +78,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) {
"dws oa approval attachment download-url",
"dws oa approval attachment authorize-download",
"dws oa approval attachment authorize-preview",
"dws oa approval attachment upload",
"临时下载链接",
"最多 10",
"最多 20",