Compare commits

...
Author SHA1 Message Date
赤川 b42596214d Merge pull request #1133 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.2-amendment
docs: amend v1.0.60-beta.2 release notes
2026-08-24 23:42:06 +08:00
chichuan f684c412ea docs: amend v1.0.60-beta.2 release notes 2026-08-24 23:37:21 +08:00
赤川 415d962371 Merge pull request #1131 from DingTalk-Real-AI/codex/fix-pretag-beta-seal-amendment
fix(policy): allow audited pre-tag beta amendments
2026-08-24 23:13:59 +08:00
chichuan 26253a4eeb fix(policy): allow audited pre-tag beta amendments 2026-08-24 23:11:30 +08:00
github-actions[bot] 8549a90402 Merge pull request #1127 from DingTalk-Real-AI/codex/param-hallucination-6-products
feat(cli): govern six product parameter aliases
2026-08-24 22:59:59 +08:00
克谨 a5dbd2e049 Merge remote-tracking branch 'origin/main' into codex/param-hallucination-6-products 2026-08-24 22:33:48 +08:00
github-actions[bot] 0ebdcb455d Merge pull request #1105 from maoqxxmm/codex/sheet-float-image-local-file
feat(sheet): support local files for float images
2026-08-24 14:30:48 +00:00
克谨 b1a6f9e20c Merge remote-tracking branch 'origin/main' into codex/param-hallucination-6-products 2026-08-24 22:03:15 +08:00
xiatian 29a0dde9d4 Merge remote-tracking branch 'upstream/main' into codex/sheet-float-image-local-file 2026-08-24 22:02:38 +08:00
克谨 0fae9dd8b3 feat(cli): govern six product parameter aliases 2026-08-24 22:01:10 +08:00
赤川 6096227511 Merge pull request #1126 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.2
chore(release): prepare v1.0.60-beta.2
2026-08-24 22:00:23 +08:00
chichuan aa265acd50 chore(release): prepare v1.0.60-beta.2 2026-08-24 21:46:54 +08:00
赤川 7210e5677c Merge pull request #1121 from hlzjsong/refresh_legacy_slot_fix
recover identity refresh from legacy global slot
2026-08-24 21:38:58 +08:00
赤川 1c90623e31 Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 21:38:34 +08:00
github-actions[bot] e7236e27d4 Merge pull request #1101 from zh-tinghe/feat/migrate-edu-contact
feat: add edu & college vendor extension commands
2026-08-24 13:33:58 +00:00
赤川 e73ebab2db Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 21:03:27 +08:00
xiatian 1b6a592387 Merge remote-tracking branch 'upstream/main' into codex/sheet-float-image-local-file 2026-08-24 20:59:28 +08:00
github-actions[bot] ce7f66ff8e Merge pull request #1117 from maoqxxmm/codex/schema-compat-float-image-one-of
fix(schema): review float image constraint transition
2026-08-24 12:46:49 +00:00
赤川 e58f255476 Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 20:28:19 +08:00
赤川 79f4f66d34 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 20:25:16 +08:00
听荷 66061bb0b0 fix(college-contact): 移除可复制破坏性示例中的确认绕过参数并补齐契约示例必填参数
CR P1-1:15 个 user_required 叶子的 Cobra Example 携带 -y/--yes,
Agent 或用户直接复制即绕过本 PR 实现的用户确认门。改为只保留
--dry-run 预览示例(含 -f json 变体),确认参数由调用方在取得
用户明确确认后自行追加。

CR P1-2:create-group-rule 的 Contract.Selection.Examples 缺少必填
--dept-type,示例实际执行会在参数校验阶段失败。同类缺陷在本文件另有
6 处(dept create / employee add / alumni list / alumni add-alumnus /
alumni update-alumnus / graduate batch-update-pending),一并对齐到
各自 Cobra Example;另修正 dept update、dept batch-update-type 契约
示例中 dept-type 取值与 Cobra 示例不一致的问题(standard -> college)。

验证:5 个产品共 153 条 Contract.Selection.Examples 全部 --dry-run
可执行(153/153 ok);changed code coverage 100.0000%。
2026-08-24 20:23:21 +08:00
xiatian 1d14258121 Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 20:15:41 +08:00
muling.cs cbbc9a9f90 refresh token legacy_recovery 2026-08-24 20:14:36 +08:00
github-actions[bot] 723d43f660 Merge pull request #1056 from AlwaysLee/feat/drive-permission-get-setting
feat(drive): add permission get-setting command
2026-08-24 12:09:36 +00:00
半圭 12d60d264d fix(drive): address review feedback on permission get-setting
- drop shareScope.required=["linkShare"] from the result schema: the
  production response omits linkShare unless link sharing is configured;
  document the conditional presence in the schema description, tests,
  and both skill references
- spell out policy value tiers in skill docs (READER_AND_ABOVE /
  DOWNLOADER_AND_ABOVE / EDITOR_AND_ABOVE / MANAGER_AND_ABOVE) so the
  shorthand no longer diverges from the schema enums
2026-08-24 19:46:13 +08:00
半圭 7bb2019dfe feat(drive): expose disabledValues with per-value reasons in get-setting schema 2026-08-24 19:46:13 +08:00
半圭 9e692ce0bf docs(drive): align get-setting semantics with final GUI wording
Sync the get-setting schema descriptions and skill references with the
server-side finalized policy texts: policy code semantics now quote the
product permission-settings page labels (e.g. external_share=add
external collaborators, watermark=show watermark, node_spread_scope=
download and distribution scope), the node_spread_scope binary values
read ALL_NODES=all documents and PREVIEWABLE_ONLY=previewable documents
only (online documents, images, videos, etc.), and the contract test
value-semantic fragments follow the same wording. Both mono and multi
drive references also list per-policy name/description examples
(external_share, node_spread with NOBODY, node_move_forbidden) quoted
verbatim from the server i18n single source.
2026-08-24 19:46:13 +08:00
半圭 e2b390217a feat(drive): align get-setting schema with self-explaining policy values
Sync the get_permission_setting result contract with the finalized
server-side enums: switch values ON/OFF become ENABLED/DISABLED and the
node_spread_scope binary ALL_CONTENT becomes ALL_NODES (restriction
applies to all nodes vs previewable-only nodes), while policy codes,
threshold domains, permissionMode and shareScope stay unchanged.
Policies now carry per-entry name and description fields (Chinese label
and value semantics from the server i18n single source) as deterministic
required members, mirrored in the contract test assertions and both mono
and multi drive skill quick references.
2026-08-24 19:46:13 +08:00
半圭 09ecaaa7a1 feat(drive): document get-setting response schema and field semantics
Declare the drive.permission get_setting ResultSpec data schema from the
server-side output metadata so the field-level contract (permissionMode,
shareScope, policy codes and their typed value domains, inherited /
allowedValues semantics) is reviewed code guarded by contract tests, and
add field-quick-reference rows for the get-setting section in both mono
and multi drive skill references.
2026-08-24 19:46:13 +08:00
半圭 95ecc99c41 fix: route drive permission get-setting to drive MCP server 2026-08-24 19:46:13 +08:00
半圭 6df6b3f8dd docs(release): use PR-numbered fragment name for drive permission get-setting 2026-08-24 19:46:13 +08:00
半圭 227861a71d feat(drive): add permission get-setting command 2026-08-24 19:46:13 +08:00
Dennis4477andDennis 20a01aaeff policy: support compatibility-visible availability migrations (#1123)
Co-authored-by: Dennis <xinyang.dxy@alibaba-inc.com>
2026-08-24 19:37:21 +08:00
赤川 7844b59a8c Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 19:08:53 +08:00
听荷 2a875eee1d Merge remote-tracking branch 'upstream/main' into feat/migrate-edu-contact 2026-08-24 19:05:05 +08:00
Dennis4477 c75eda1a2b Merge pull request #1118 from DingTalk-Real-AI/codex/devapp-availability-migration-plan
policy: plan DevApp availability hardening migration
2026-08-24 18:49:08 +08:00
听荷 662352202c docs(skills): complete edu-app command surface and fix dead cross-product ref
Skill 参考此前只描述了 edu-app 42 条叶子里的 16 条,homework(11)/diploma(9)
与 notice 的 6 条全部缺失,导致 Agent 把"作业/通知"一律路由到 task 与
notice confirm;三条 user_required 破坏性命令(notice/homework/diploma
delete)也落在未记录区间,PR 里的确认门禁说明因此无法被文档兑现。
edu-familygroup 的上下文传递表引用了不存在的 edu-contact family list,
真正返回家长信息的是 family parents。

check-skill-commands.sh 只校验以 `dws ` 开头的反引号片段,因此上述两处
死引用能在门禁全绿的情况下存活;本次给跨产品引用统一补上 dws 前缀,
让该门禁从现在起覆盖它们。

同时修正扫描测试的陈旧计数(153→156、edu-contact 26→29、class 18→19,
以 DeclareLeafMetadata 声明数为准),并给 edu-familygroup 第 6 条叶子
manage add-child 补上工具名与整体入参的精确断言。
2026-08-24 18:33:08 +08:00
xiatian fa2d54fd42 Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 18:29:05 +08:00
Dennis c5d58ec49d policy: plan devapp shortcut availability hardening 2026-08-24 18:27:12 +08:00
github-actions[bot] adbc9aac3e Merge pull request #1120 from DingTalk-Real-AI/codex/param-hallucination-8-products
feat(cli): optimize parameter aliases for eight products
2026-08-24 18:25:44 +08:00
xiatian 82a87bf9ce Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 18:24:13 +08:00
muling.cs 0bfb1ff1de chore: add release fragment for legacy global slot recovery 2026-08-24 18:11:07 +08:00
muling.cs 426ad50927 refresh use legacy global slot 2026-08-24 18:06:05 +08:00
克谨 b57cecb62d feat(cli): optimize eight shortcut parameter aliases 2026-08-24 17:51:45 +08:00
Dennis4477 8dc52d052c Merge pull request #1113 from DingTalk-Real-AI/codex/devapp-availability-compat-governance
policy: add consumable Schema availability migrations
2026-08-24 17:39:07 +08:00
xiatian f8e3f0b12a fix(schema): review float image constraint transition 2026-08-24 17:09:11 +08:00
听荷 aab67202a0 Merge remote-tracking branch 'origin/feat/migrate-edu-contact' into feat/migrate-edu-contact 2026-08-24 17:02:24 +08:00
听荷 2e60b8e121 test: pair confirm-gate and exact-dispatch assertions for all destructive edu leaves
Auto-CR flagged that the destructive-command tests only checked the error
string, so a regression that let a command slip past the confirmation gate
(or dispatch the wrong payload) would go unnoticed.

Every user_required leaf across college-contact (15), edu-contact (2),
edu-group (1) and edu-app (3) is now verified in pairs with a fresh
non-dry-run capture caller: without explicit confirmation the gate must
return confirmation_required AND the caller must see zero calls; with
--yes the command must produce exactly one call carrying the correct
productID, tool name and complete argument payload.
2026-08-24 17:01:57 +08:00
Dennis 6147021ae8 policy: add consumable schema availability migrations 2026-08-24 16:47:19 +08:00
赤川 f84fc1d684 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 16:46:40 +08:00
赤川 ae31d371de Merge pull request #1114 from DingTalk-Real-AI/codex/fix-policy-timeout-governance
ci: extend Policy job timeout for large Schema surfaces
2026-08-24 16:46:22 +08:00
chichuan c20a53cd8a ci: extend Policy job timeout 2026-08-24 16:43:10 +08:00
xiatian 2dc52afd60 fix(sheet): align float image schema constraints 2026-08-24 16:36:12 +08:00
赤川 21bca3025f Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 16:07:50 +08:00
听荷 874b5b091a Merge remote-tracking branch 'upstream/main' into feat/migrate-edu-contact 2026-08-24 15:58:52 +08:00
github-actions[bot] 1bb56364c2 Merge pull request #1111 from DingTalk-Real-AI/codex/ci-windows-coverage-governance
ci: bound exhaustive coverage test runtimes
2026-08-24 15:54:27 +08:00
赤川 cd93b7fdc2 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 15:34:40 +08:00
克谨 758b0f875e ci: bound exhaustive coverage test runtimes 2026-08-24 15:28:00 +08:00
xiatian fdb5fc19f5 test(sheet): cover float image redirect rejection 2026-08-24 14:48:12 +08:00
xiatian 583d31141d feat(sheet): support local files for float images 2026-08-24 14:48:12 +08:00
github-actions[bot] 19be571574 Merge pull request #1107 from DingTalk-Real-AI/codex/devdoc-hrbrain-pat-shortcuts
feat(shortcut): harden Devdoc HRbrain and PAT surfaces
2026-08-24 14:24:48 +08:00
听荷 38360969e7 style: gofmt college_contact files 2026-08-24 13:36:31 +08:00
听荷 f9277ae1d3 fix: use standard confirmation framework for college-contact destructive commands
Remove custom collegeContactConfirmDestructive function and rely on the
standard framework Confirmation: "user_required" in LeafSpec. This ensures
destructive commands return the proper category:validation/code:3 error
instead of category:internal/code:5.

Also adds run_all_edu_commands_test.go covering all 153 leaf commands
across 5 edu/college products.
2026-08-24 13:30:32 +08:00
听荷 232d9dee6e fix: remove edu products from command matrix to avoid Windows timeout
The command matrix test with 5 additional edu products (153 leaf commands)
exceeds the 10-minute go test timeout on Windows CI runners. The dedicated
TestCrossPlatformCoverage* tests in individual edu test files already
provide 100% changed-code coverage without the matrix.
2026-08-24 13:30:32 +08:00
听荷 c2528f1fc8 fix: add CrossPlatformCoverage prefix to edu tests for platform gate
The CI platform coverage gate (macOS/Windows) only executes tests
matching ^(TestAllShortcuts|TestCrossPlatformCoverage). The edu/college
test functions used standard names and were not exercised during the
platform coverage run, causing 73.89% changed-code coverage (target 100%).

Changes:
- Rename all edu/college test functions with TestCrossPlatformCoverage prefix
- Add edu products to command_matrix_test.go selected map
- Add TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints
  in pkg/edition to cover the new supplement server entries

Local verification: coverage-gate-platform reports 100.0000% (3336 stmts).
2026-08-24 13:30:32 +08:00
听荷 1b22b79fa9 fix: replace --help examples with real execution examples in college-contact
The schema catalog validator requires Examples to demonstrate actual
execution, not just --help. Replace all 65 occurrences with realistic
parameter examples derived from each command's required flags.
2026-08-24 13:30:32 +08:00
听荷 f3e98d55e8 feat: add edu & college vendor extension commands
Add five hidden vendor extension commands for education scenarios:
- edu-contact: school/class/family/teacher contact management
- edu-group: student/class group lifecycle
- edu-app: homework, notices, report cards, diplomas, class circles
- edu-familygroup: family group management, child binding, app permissions
- college-contact: university dept/employee/alumni/graduate management

All route to dedicated MCP servers via callMCPToolOnServer with endpoints
registered in openSupplementServers (helper-only, not VisibleProducts).

Includes: helper implementations, dispatch tests, mono/multi skill docs,
coverage.yaml registration, context-budget compliance, and .changes fragment.
2026-08-24 13:30:32 +08:00
72 changed files with 22950 additions and 788 deletions
@@ -0,0 +1,5 @@
---
category: Added
---
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
+8 -3
View File
@@ -544,7 +544,8 @@ jobs:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-r
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
@@ -692,7 +693,8 @@ jobs:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-r
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
@@ -1597,7 +1599,10 @@ jobs:
name: Policy
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
# Full policy regenerates and validates the runtime Schema several times.
# Keep job-level headroom for large reviewed command-surface additions;
# individual policy gates retain their own fail-closed checks.
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
+80
View File
@@ -6,6 +6,86 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.60-beta.2] - 2026-08-24
### Added
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
- **Permission and member list pagination** (#1085) — `drive/doc permission
list` and `wiki member list` now accept `--next-token` to follow the
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
200` path; `permission add/update/remove` and `wiki member add/update/remove`
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
grantee types. The optional `--notify` defaults to `false` and is omitted from
the server request unless passed explicitly, so member grants no longer notify
recipients by default. These commands also declare cursor pagination
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
change. Because a single batch remove can revoke access for up to 30
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
groups can indirectly affect many more users — `drive/doc permission
remove` and `wiki member remove` now declare
`confirmation=user_required` and gate the actual tool call behind user
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
confirmation-gate failure now also passes through verbatim instead of being
reclassified as a permission-denied or unclassified error.
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
- **Doc public-link and historical-version reads** — `dws doc read` forwards
the reviewed `password` (internet-public documents with password protection)
and `historyVersion` (read content as of a listed historical version; `0`
denotes the document's initial version) parameters on the markdown, JSONML,
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
`--password` and `--version` with the same `historyVersion` forwarding, while
`--revision` stays rejected with explicit guidance: revision is the document
edit revision returned by JSONML reads for `+update --expected-revision`
conditional writes, not a historical version number.
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
- **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.
- **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.
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
### Changed
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
- **Permission error guidance and error rendering** (#1085) —
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
instead of a generic business-error rendering; document/wiki-specific errors
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
or the role-threshold wording like
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
(`dws drive permission apply-info` / `dws drive permission apply`), while
permission failures carrying only generic code names (`FORBIDDEN`,
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
or other products' wording keep their product-specific or
product-neutral suggestion instead of a misleading document-permission hint;
member-validation failures such as
“用户不存在/不属于当前组织” are classified as tool errors with a
`--members`-with-`corpId` suggestion instead of a misleading
resource-not-found error; business error output now surfaces the backend
message with `code`/`logId` appended for traceability; and the
`update_permission` / `remove_permission` / `update_member` /
`remove_member` tools — whose servers return a literal `null` on successful
no-payload writes — now render `{}` so downstream JSON consumers do not fail
parsing `null`; other tools keep raw `null` output unchanged.
### Fixed
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
## [1.0.60-beta.1] - 2026-08-21
### Changed
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
@@ -221,8 +221,8 @@ var paramAliasCompleteCommands = map[string][]string{
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
@@ -231,12 +231,113 @@ var paramAliasCompleteCommands = map[string][]string{
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for the
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
// separate map lets this test file land before the draft replaces the formal
// paramAliasCandidateCompleteCommands contains complete invocations for
// reviewed parameter-concept product drafts. Keeping candidate-only commands
// in a separate map lets a test change land before a draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
"contact +org": {"contact", "+org", "--name", "Fixture User"},
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
"contact +team": {"contact", "+team", "--name", "Fixture User"},
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
"report +template-search": {"report", "+template-search", "--query", "fixture"},
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
@@ -296,6 +397,20 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"markdown create": {
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
},
"markdown diff": {
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
},
"markdown overwrite": {
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
},
"markdown patch": {
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
},
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
@@ -969,6 +1084,122 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
}
}
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
// exercises every distinct reviewed mutating complete-command template in the
// reviewed product expansions. The fixture gate already proves every
// alias resolves through PreParse; this gate removes the confirmation flag
// from one active alias invocation per distinct template and requires the
// runtime boundary to stop it before the first transport call. An explicit
// --dry-run is a reviewed preview path and must not carry a bypass flag.
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
requiredTemplates := make(map[string]bool)
coveredTemplates := make(map[string]bool)
for _, fixture := range concepts.Fixture {
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
continue
}
product, _, _ := strings.Cut(fixture.Command, " ")
switch product {
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
default:
continue
}
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
continue
}
_, yesCount := removeExactArg(complete, "--yes")
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
confirmationCount := yesCount + userSayYesCount
confirmationArg := "--yes"
if userSayYesCount == 1 {
confirmationArg = "--user-say-yes"
}
_, dryRunCount := removeExactArg(complete, "--dry-run")
if dryRunCount > 1 {
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
continue
}
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
switch meta.Safety.Confirmation {
case "user_required":
if dryRunCount == 1 {
if confirmationCount != 0 {
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
}
continue
}
if confirmationCount != 1 {
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
case "not_required":
if confirmationCount != 0 {
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
}
}
if confirmationCount == 0 {
continue
}
if confirmationCount != 1 {
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
continue
}
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
requiredTemplates[templateKey] = true
if coveredTemplates[templateKey] {
continue
}
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
continue
}
coveredTemplates[templateKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
})
}
if len(requiredTemplates) == 0 {
t.Fatal("reviewed complete-command templates contain no confirmation cases")
}
if len(coveredTemplates) != len(requiredTemplates) {
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
}
}
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
t.Helper()
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
if removals != 1 {
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
}
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed alias command skipped PreParse")
}
var appErr *apperrors.Error
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
return
}
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
}
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
t.Helper()
canonicalCaller := &paramAliasCaptureCaller{}
@@ -0,0 +1,70 @@
package app
import (
"testing"
)
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
"sheet.create_float_image",
"sheet.update_float_image",
)
create := payload.Tools["sheet.create_float_image"]
if create == nil {
t.Fatal("missing sheet.create_float_image")
}
if create["interface_mode"] != "mcp" {
t.Fatalf("create interface mode = %#v", create["interface_mode"])
}
createRef, _ := create["interface_ref"].(map[string]any)
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
t.Fatalf("create interface ref = %#v", createRef)
}
createDryRun, _ := create["dry_run"].(map[string]any)
if createDryRun["preview_kind"] != "request" {
t.Fatalf("create dry-run = %#v", createDryRun)
}
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
}
createParameters, _ := create["parameters"].(map[string]any)
file, _ := createParameters["file"].(map[string]any)
src, _ := createParameters["src"].(map[string]any)
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
t.Fatalf("create --file metadata = %#v", file)
}
if schemaContractString(file["property"]) != "" {
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
}
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
t.Fatalf("create --src compatibility metadata = %#v", src)
}
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
update := payload.Tools["sheet.update_float_image"]
if update == nil {
t.Fatal("missing sheet.update_float_image")
}
updateDryRun, _ := update["dry_run"].(map[string]any)
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
}
updateParameters, _ := update["parameters"].(map[string]any)
updateFile, _ := updateParameters["file"].(map[string]any)
if schemaContractString(updateFile["property"]) != "" {
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
}
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
root := NewRootCommand()
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
command := exactCommandForTest(root, cliPath)
if command == nil || command.Flags().Lookup("file") == nil {
t.Fatalf("%s has no executable --file flag", cliPath)
}
}
}
+123
View File
@@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
// The organization mirror may be absent for long-lived local logins
// that predate mirror publication. Recover from the legacy global
// slot before giving up.
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
return recovered, nil
}
return nil, rErr
}
if p.logger != nil {
@@ -891,6 +897,123 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa
return refreshed, nil
}
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
var exchangeErr *MCPTokenExchangeError
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
return nil, refreshErr
}
if selected == nil {
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
"corp_id", strings.TrimSpace(selected.CorpID),
"user_id", strings.TrimSpace(selected.UserID),
"refresh_error_code", exchangeErr.Code,
)
legacy, loadErr := tokenLoadKeychain()
if loadErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
return nil, refreshErr
}
if legacy == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
return nil, refreshErr
}
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
"step", "candidate_mismatch",
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
"legacy_user_id", strings.TrimSpace(legacy.UserID),
)
return nil, refreshErr
}
recovered := *legacy
if strings.TrimSpace(recovered.UserID) == "" {
recovered.UserID = strings.TrimSpace(selected.UserID)
}
if strings.TrimSpace(recovered.UserName) == "" {
recovered.UserName = strings.TrimSpace(selected.UserName)
}
if recovered.IsAccessTokenValid() {
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
return &recovered, nil
}
if !recovered.IsRefreshTokenValid() {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
return nil, refreshErr
}
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
return nil, refreshErr
}
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
return nil, refreshErr
}
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
if recoverErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
return refreshed, nil
}
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
if selected == nil || legacy == nil {
return false
}
selectedCorpID := strings.TrimSpace(selected.CorpID)
legacyCorpID := strings.TrimSpace(legacy.CorpID)
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
return false
}
selectedUserID := strings.TrimSpace(selected.UserID)
legacyUserID := strings.TrimSpace(legacy.UserID)
if legacyUserID != "" {
return legacyUserID == selectedUserID
}
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
}
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
if strings.TrimSpace(corpID) == "" {
return false
}
cfg, err := tokenLoadProfiles(configDir)
if err != nil || cfg == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
return false
}
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) != 1 {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "multi_profile",
"corp_id", strings.TrimSpace(corpID),
"profile_count", len(profiles),
)
return false
}
profile := profiles[0]
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
return true
}
profileUserID := ""
if profile != nil {
profileUserID = strings.TrimSpace(profile.UserID)
}
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "identity_mismatch",
"selected_user_id", strings.TrimSpace(userID),
"profile_user_id", profileUserID,
)
return false
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
// external host, exchanges it for tokens, and persists them.
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
+517
View File
@@ -15,6 +15,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "V1039 User",
CorpID: selected.CorpID,
UserID: selected.UserID,
UserName: selected.UserName,
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
var saved *TokenData
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
copy := *data
saved = &copy
return nil
})
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
}
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
}
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-multi",
UserID: "user-v1039-a",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-blank-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-user-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: "user-v1039-other",
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token owned by a different user")
}
}
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
expired := *fixture.blankToken
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
}
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-single-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "Other User",
CorpID: selected.CorpID,
UserID: "user-v1039-other",
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-legacy-refresh",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
refreshed := &TokenData{
AccessToken: "refreshed-legacy-access",
RefreshToken: "rotated-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
refreshCalls := 0
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
refreshCalls++
if refreshCalls == 1 {
return nil, rejection
}
return refreshed, nil
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if refreshCalls != 2 {
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
}
if recovered.AccessToken != refreshed.AccessToken {
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
}
if recovered.RefreshToken != refreshed.RefreshToken {
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-profiles-error",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "shared-rejected-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-same-refresh",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: selected.RefreshToken,
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
provider := NewOAuthProvider(t.TempDir(), nil)
// nil selected must be rejected before any dereference.
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil selected error = %v, want original rejection", err)
}
// A keychain load that returns (nil, nil) must be rejected before any dereference.
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
selected := &TokenData{
CorpID: "corp-v1039-nil-legacy",
UserID: "user-v1039",
Source: "mcp",
}
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil legacy error = %v, want original rejection", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
provider := NewOAuthProvider(t.TempDir(), nil)
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
plainErr := errors.New("plain refresh failure")
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
t.Fatalf("plain error = %v, want original plain failure", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
selected := &TokenData{
CorpID: "corp-v1039-recovery-steps",
UserID: "user-v1039",
Source: "mcp",
}
t.Run("save_failure", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "valid-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("save failure error = %v, want original rejection", err)
}
})
t.Run("refresh_expired", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "expired-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(-time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("expired refresh error = %v, want original rejection", err)
}
})
t.Run("refresh_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, errors.New("legacy refresh failed")
})
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("legacy refresh error = %v, want original rejection", err)
}
})
t.Run("preflight_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("preflight error = %v, want original rejection", err)
}
})
}
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
configDir := t.TempDir()
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
t.Fatal("nil legacy accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
t.Fatal("different corp accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
t.Fatal("blank selected corp accepted")
}
blankSelected := &TokenData{CorpID: selected.CorpID}
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
})
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
t.Fatal("both blank user IDs should match only through the single-profile guard")
}
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
t.Fatal("blank selected with non-blank legacy accepted")
}
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
t.Fatal("blank corp accepted by single-profile check")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -96,7 +96,14 @@ func init() {
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src"}},
})
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
})
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
registerRequireOneOf("sheet.import", "folder-token", "workspace")
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
@@ -554,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
@@ -646,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) {
cmd := newCollegeContactCommand()
if cmd.Name() != "college-contact" {
t.Errorf("expected name 'college-contact', got %q", cmd.Name())
}
if !cmd.Hidden {
t.Error("extension root command should be Hidden")
}
// 分组 → 叶子命令映射
groups := map[string][]string{
"dept": {
"get-standard-structure", "get-detail", "get-chain", "search",
"create", "update", "delete", "batch-update-type", "overview",
},
"employee": {
"get-detail", "add", "remove", "change-type", "change-dept",
"send-active-sms", "list-employees", "list-unaccepted",
"list-unactive", "upgrade-status", "start-upgrade",
},
"alumni": {
"get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins",
},
"graduate": {
"query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore",
},
"group": {
"query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule",
},
}
for groupName, leaves := range groups {
var groupCmd *cobra.Command
for _, c := range cmd.Commands() {
if c.Name() == groupName {
groupCmd = c
break
}
}
if groupCmd == nil {
t.Fatalf("subcommand group %q not found", groupName)
}
for _, leaf := range leaves {
found := false
for _, c := range groupCmd.Commands() {
if c.Name() == leaf {
found = true
break
}
}
if !found {
t.Errorf("leaf command %q not found under %q", leaf, groupName)
}
}
}
// stats 分组已移除
for _, c := range cmd.Commands() {
if c.Name() == "stats" {
t.Error("subcommand group 'stats' should be removed")
}
}
}
func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(newCollegeContactCommand())
c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"})
if err != nil {
t.Fatalf("command path not found: %v", err)
}
if c.Name() != "get-standard-structure" {
t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name())
}
}
// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上
// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newCollegeContactTestRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
return root
}
// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。
// 单测环境未初始化 products 运行时依赖,若门禁放行后进入
// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分
// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。
func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) {
t.Helper()
root := newCollegeContactTestRoot()
root.SetArgs(args)
defer func() {
if r := recover(); r != nil {
panicked = true
}
}()
err = root.Execute()
return err, false
}
func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if panicked {
t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args)
}
if err == nil {
t.Fatalf("%v: 未传 --yes 应拒绝执行", args)
}
if !strings.Contains(err.Error(), "--yes") {
t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err)
}
}
}
func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if !panicked {
// 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败
if err != nil && strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err)
}
}
}
}
// withCollegeContactCaller installs a dry-run capture caller so happy-path
// command execution exercises each RunE up to the callMCPToolOnServer dispatch
// without requiring a live MCP transport. In dry-run mode destructive
// commands' confirm gate short-circuits to nil, so no --yes flag is needed.
func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactHappyPaths runs every leaf command with required flags only
// and with all optional flags populated, expecting a nil error (dry-run preview).
func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure"},
{"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-detail", "--dept-id", "123"},
{"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-chain", "--dept-id", "123"},
{"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "search", "--dept-id", "123", "--keyword", "k"},
{"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "delete", "--dept-id", "123"},
{"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"},
{"dept", "overview"},
{"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail", "--staff-id", "S1"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "remove", "--staff-ids", "S1"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "send-active-sms", "--dept-id", "100"},
{"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-employees", "--dept-id", "123"},
{"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unaccepted", "--dept-id", "123"},
{"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unactive", "--dept-id", "123"},
{"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "upgrade-status"},
{"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"employee", "start-upgrade"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree", "--alumni-dept-id", "123"},
{"alumni", "get-info", "--alumni-dept-id", "123"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"},
{"alumni", "query", "--staff-id", "S1"},
{"alumni", "search", "--keyword", "x"},
{"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"},
{"alumni", "get-group", "--alumni-dept-id", "1"},
{"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "delete-dept", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
{"alumni", "create-group", "--alumni-dept-id", "1"},
{"alumni", "disband-group", "--alumni-dept-id", "1"},
{"alumni", "get-alumni-org-from-graduate"},
{"alumni", "create-alumni-org", "--org-name", "O"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-years"},
{"graduate", "query-graduate-depts", "--dept-id", "1"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"},
{"graduate", "get-task-result", "--request-no", "r1"},
{"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"},
{"graduate", "get-alumni-org"},
{"graduate", "query-restore-sub-depts", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"},
{"graduate", "search-graduate", "--keyword", "x"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "all-graduate", "--graduate-year", "2026"},
{"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule"},
{"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"},
{"group", "get-group-rule-schedule"},
{"group", "query-preview-data"},
{"group", "query-preview-data", "--offset", "0", "--size", "20"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"},
{"group", "delete-group-rule", "--rule-id", "1"},
{"group", "enable-group-rule", "--rule-id", "1"},
{"group", "disable-group-rule", "--rule-id", "1"},
{"group", "set-group-rule-schedule"},
{"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"},
{"group", "execute-group-rule"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err != nil {
t.Errorf("%v: expected nil error, got: %v", args, err)
}
}
}
// TestCollegeContactValidationErrors exercises every validation-error branch:
// missing required flags, non-integer int flags, invalid bool flags, and
// empty-after-split CSV lists. Each case must return a non-nil error.
func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure", "--dept-id", "abc"},
{"dept", "get-standard-structure", "--offset", "abc"},
{"dept", "get-standard-structure", "--size", "abc"},
{"dept", "get-detail"},
{"dept", "get-detail", "--dept-id", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--size", "abc"},
{"dept", "get-chain"},
{"dept", "get-chain", "--dept-id", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--size", "abc"},
{"dept", "search"},
{"dept", "search", "--dept-id", "abc", "--keyword", "k"},
{"dept", "search", "--dept-id", "1"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"},
{"dept", "create"},
{"dept", "create", "--super-id", "abc"},
{"dept", "create", "--super-id", "100"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"},
{"dept", "update"},
{"dept", "update", "--dept-id", "abc"},
{"dept", "update", "--dept-id", "1"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "delete"},
{"dept", "delete", "--dept-id", "abc"},
{"dept", "batch-update-type"},
{"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", "1,2"},
{"dept", "overview", "--dept-id", "abc"},
{"dept", "overview", "--offset", "abc"},
{"dept", "overview", "--size", "abc"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"},
{"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"},
{"employee", "add"},
{"employee", "add", "--emp-type", "x"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"},
{"employee", "remove"},
{"employee", "remove", "--staff-ids", ",,"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"},
{"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"},
{"employee", "change-type"},
{"employee", "change-type", "--staff-id", "S1"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"},
{"employee", "change-dept"},
{"employee", "change-dept", "--staff-id", "S1"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"},
{"employee", "send-active-sms"},
{"employee", "send-active-sms", "--dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-employees"},
{"employee", "list-employees", "--dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unaccepted"},
{"employee", "list-unaccepted", "--dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unactive"},
{"employee", "list-unactive", "--dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "upgrade-status", "--dept-id", "abc"},
{"employee", "upgrade-status", "--offset", "abc"},
{"employee", "upgrade-status", "--size", "abc"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree"},
{"alumni", "get-dept-tree", "--alumni-dept-id", "abc"},
{"alumni", "get-info"},
{"alumni", "get-info", "--alumni-dept-id", "abc"},
{"alumni", "list"},
{"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"},
{"alumni", "query"},
{"alumni", "search"},
{"alumni", "search", "--keyword", "x", "--offset", "abc"},
{"alumni", "list-unaccepted"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "abc"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"},
{"alumni", "get-group"},
{"alumni", "get-group", "--alumni-dept-id", "abc"},
{"alumni", "create-dept"},
{"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "create-dept", "--alumni-dept-id", "1"},
{"alumni", "update-dept"},
{"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1"},
{"alumni", "delete-dept"},
{"alumni", "delete-dept", "--alumni-dept-id", "abc"},
{"alumni", "update-managers"},
{"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"},
{"alumni", "update-managers", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"},
{"alumni", "add-alumnus"},
{"alumni", "add-alumnus", "--name", "X"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"},
{"alumni", "update-alumnus"},
{"alumni", "update-alumnus", "--staff-id", "S1"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"},
{"alumni", "remove-alumnus"},
{"alumni", "remove-alumnus", "--staff-id", "S1"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"},
{"alumni", "cancel-invite"},
{"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"},
{"alumni", "create-group"},
{"alumni", "create-group", "--alumni-dept-id", "abc"},
{"alumni", "disband-group"},
{"alumni", "disband-group", "--alumni-dept-id", "abc"},
{"alumni", "create-alumni-org"},
{"alumni", "add-alumni-org-main-admins"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-depts"},
{"graduate", "query-graduate-depts", "--dept-id", "abc"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"},
{"graduate", "query-graduate-sub-depts"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users"},
{"graduate", "query-page-graduate-users", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"},
{"graduate", "get-task-result"},
{"graduate", "query-restore-sub-depts"},
{"graduate", "query-restore-sub-depts", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"},
{"graduate", "search-graduate"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"},
{"graduate", "commit-graduate"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "abc"},
{"graduate", "commit-graduate", "--graduate-dept-ids", ",,"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"},
{"graduate", "all-graduate"},
{"graduate", "all-graduate", "--graduate-year", "abc"},
{"graduate", "batch-graduate"},
{"graduate", "batch-graduate", "--dept-id", "abc"},
{"graduate", "batch-graduate", "--dept-id", "1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "delete-and-graduate"},
{"graduate", "delete-and-graduate", "--dept-id", "abc"},
{"graduate", "delete-and-graduate", "--dept-id", "1"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-delete-pending"},
{"graduate", "batch-delete-pending", "--dept-id", "abc"},
{"graduate", "batch-delete-pending", "--dept-id", "1"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending"},
{"graduate", "batch-update-pending", "--dept-id", "abc"},
{"graduate", "batch-update-pending", "--dept-id", "1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"},
{"graduate", "commit-restore"},
{"graduate", "commit-restore", "--graduate-dept-ids", "abc"},
{"graduate", "commit-restore", "--graduate-dept-ids", ",,"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule", "--offset", "abc"},
{"group", "query-group-rule", "--size", "abc"},
{"group", "query-preview-data", "--offset", "abc"},
{"group", "query-preview-data", "--size", "abc"},
{"group", "create-group-rule"},
{"group", "create-group-rule", "--name", "X"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"},
{"group", "delete-group-rule"},
{"group", "delete-group-rule", "--rule-id", "abc"},
{"group", "enable-group-rule"},
{"group", "enable-group-rule", "--rule-id", "abc"},
{"group", "disable-group-rule"},
{"group", "disable-group-rule", "--rule-id", "abc"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err == nil {
t.Errorf("%v: expected non-nil error, got nil", args)
}
}
}
// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every
// user_required destructive leaf in a paired manner:
// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls).
// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct
// productID, tool name, and complete argument payload.
func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) {
type destructiveCase struct {
name string
args []string
wantTool string
wantInput map[string]any
}
cases := []destructiveCase{
{
"dept delete",
[]string{"college-contact", "dept", "delete", "--dept-id", "123"},
"delete_college_contact_dept",
map[string]any{"deptId": int64(123)},
},
{
"employee remove",
[]string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"},
"remove_employee",
map[string]any{"staffIds": []string{"S1", "S2"}},
},
{
"alumni delete-dept",
[]string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"},
"delete_alumni_dept",
map[string]any{"alumniDeptId": int64(1)},
},
{
"alumni remove-alumnus",
[]string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
"delete_alumnus",
map[string]any{"staffId": "S1", "alumniDeptId": int64(1)},
},
{
"alumni cancel-invite",
[]string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
"delete_alumni_invite_record",
map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"alumni disband-group",
[]string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"},
"disband_alumni_group",
map[string]any{"alumniDeptId": int64(1)},
},
{
"graduate commit-graduate",
[]string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
"commit_graduate",
map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)},
},
{
"graduate all-graduate",
[]string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"},
"all_graduate",
map[string]any{"graduateYear": int64(2026)},
},
{
"graduate batch-graduate",
[]string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate delete-and-graduate",
[]string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"delete_and_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-delete-pending",
[]string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_delete_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-update-pending",
[]string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
"batch_update_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)},
},
{
"graduate commit-restore",
[]string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
"commit_restore",
map[string]any{"graduateDeptIds": []int64{1, 2}},
},
{
"group delete-group-rule",
[]string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"},
"delete_group_rule",
map[string]any{"ruleId": int64(1)},
},
{
"group execute-group-rule",
[]string{"college-contact", "group", "execute-group-rule"},
"execute_group_rule",
map[string]any{},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
argsWithYes := append(append([]string{}, tc.args...), "--yes")
root.SetArgs(argsWithYes)
err := root.Execute()
if err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "college-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
// withCollegeContactDispatchCaller installs a non-dry-run capture caller so
// commands go through the full dispatch path (deps.Caller.CallTool) and we can
// verify the productID, tool name, and args passed to callMCPToolOnServer.
func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactDispatch verifies that representative commands from each
// group dispatch to the correct MCP tool with the expected productID and args.
func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) {
type dispatchCase struct {
name string
args []string
wantTool string
wantProd string
checkArgs func(t *testing.T, args map[string]any)
}
cases := []dispatchCase{
{
name: "dept get-standard-structure",
args: []string{"college-contact", "dept", "get-standard-structure"},
wantTool: "get_college_standard_structure",
wantProd: "college-contact",
},
{
name: "dept get-detail",
args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"},
wantTool: "get_college_dept_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
{
name: "dept create",
args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
wantTool: "create_college_contact_dept",
wantProd: "college-contact",
},
{
name: "employee get-detail",
args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"},
wantTool: "get_employee_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["staffId"] != "S1" {
t.Errorf("staffId = %v, want S1", input["staffId"])
}
},
},
{
name: "alumni get-dept-tree",
args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"},
wantTool: "get_alumni_dept_tree",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["alumniDeptId"] != int64(123) {
t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"])
}
},
},
{
name: "graduate query-graduate-years",
args: []string{"college-contact", "graduate", "query-graduate-years"},
wantTool: "query_graduate_years",
wantProd: "college-contact",
},
{
name: "group query-group-rule",
args: []string{"college-contact", "group", "query-group-rule"},
wantTool: "query_group_rule",
wantProd: "college-contact",
},
{
name: "dept delete with --yes",
args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"},
wantTool: "delete_college_contact_dept",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := withCollegeContactDispatchCaller(t)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if caller.productID != tc.wantProd {
t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd)
}
if caller.tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool)
}
if tc.checkArgs != nil {
tc.checkArgs(t, caller.args)
}
})
}
}
@@ -10,6 +10,8 @@ import (
"runtime"
"sync"
"testing"
"github.com/spf13/cobra"
)
const (
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
if os.Getenv(helpersShellStubEnv) == "1" {
os.Exit(runHelpersShellStub())
}
// Cobra's Windows pre-exec hook walks the process table on every Execute*
// call to detect Explorer launches. Helpers tests execute command trees
// thousands of times, and none of those in-process invocations can be an
// Explorer launch, so keep that production-only check out of the test
// process. This also prevents every new exhaustive harness from having to
// remember a test-local override.
originalMousetrapHelpText := cobra.MousetrapHelpText
cobra.MousetrapHelpText = ""
code := m.Run()
cobra.MousetrapHelpText = originalMousetrapHelpText
if helpersShellStubBaseDir != "" {
_ = os.RemoveAll(helpersShellStubBaseDir)
}
+123 -2
View File
@@ -2336,6 +2336,127 @@ func newDriveCommand() *cobra.Command {
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermGetSettingCmd := &cobra.Command{
Use: "get-setting",
Short: "查询节点权限设置",
Long: `查询文档空间节点的权限设置,返回三部分配置:
- permissionMode: 权限模式(INHERITED 继承上级 / INDEPENDENT 独立管理)
- shareScope: 分享范围(可见范围、链接分享设置)
- policies: 权限策略列表(水印、组织外分享、成员邀请门槛等)
查询协作者列表请改用 permission list。`,
Example: ` dws drive permission get-setting --node DOC_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "get_permission_setting", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(drivePermGetSettingCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_permission_setting",
CanonicalPath: "drive.get_permission_setting",
CLIPath: "drive permission get-setting",
PrimaryCLIPath: "drive permission get-setting",
},
Description: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"节点权限设置(权限模式/分享范围/权限策略)",
"properties":{
"docUrl":{"type":"string","description":"当前查询节点的文档访问链接,可直接在浏览器中打开"},
"nodeId":{"type":"string","description":"当前查询节点的 nodeId(入参解析后的规范形式)"},
"permissionMode":{"type":["string","null"],"enum":["INHERITED","INDEPENDENT",null],"description":"权限模式:INHERITED=继承上级权限配置,INDEPENDENT=独立管理权限;未知时为 null"},
"shareScope":{
"type":"object",
"description":"分享范围设置",
"properties":{
"visibility":{"type":["string","null"],"enum":["PRIVATE","ORGANIZATION","PUBLIC",null],"description":"PRIVATE=仅指定成员可见,ORGANIZATION=组织内公开,PUBLIC=互联网公开;未知时为 null"},
"partnerIncluded":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义,true 表示组织内公开范围包含合作伙伴(含生态组织外部协作成员)。其余场景为 false。"},
"defaultRole":{"type":["string","null"],"enum":["READER","DOWNLOADER","EDITOR","MANAGER",null],"description":"仅 visibility=ORGANIZATION 时有意义,通过链接获得访问的默认角色;未下发或不在值域内时为 null"},
"canSearch":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"canRecommend":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"linkShare":{
"type":"object",
"description":"链接分享设置;仅开启链接分享时返回,未开启时该字段不返回",
"properties":{
"requirePassword":{"type":"boolean","description":"true 表示通过链接访问需要提供密码。密码明文不会返回。"},
"expireAt":{"type":["integer","null"],"description":"秒级 Unix 时间戳,未设置过期时为 null。"},
"expireDays":{"type":["integer","null"],"description":"设置的有效天数,未设置时为 null。"},
"forCurrentNode":{"type":"boolean","description":"true 表示该分享范围仅作用于当前节点;false 表示作用于当前节点及其子节点。"}
},
"additionalProperties":true
}
},
"additionalProperties":true
},
"policies":{
"type":"array",
"description":"仅包含支持的策略项,未下发或不受支持的策略不会返回;node_spread_scope 仅文件夹类节点返回;allowedValues 为当前可设置的取值,disabledValues 为当前不可设置的取值及原因,两者互斥",
"items":{
"type":"object",
"description":"权限策略项",
"properties":{
"code":{"type":"string","enum":["external_share","external_share_manager_only","member_invite","member_invite_org_only","comment","permission_apply","external_permission_apply","watermark","node_spread","online_content_copy","node_move_forbidden","node_spread_scope"],"description":"external_share=添加企业外协作者;external_share_manager_only=企业外协作者仅限管理员;member_invite=谁可以添加协作者;member_invite_org_only=仅企业内用户可添加协作者;comment=谁可以评论;permission_apply=权限申请;external_permission_apply=组织外权限申请;watermark=显示水印;node_spread=谁可以下载、创建副本、打印;online_content_copy=谁可以复制文档内容;node_move_forbidden=禁止移动;node_spread_scope=下载与传播生效范围(仅文件夹类节点)。"},
"name":{"type":"string","description":"策略的中文名称,文案与产品权限设置页一致;为确定性字段,只要该策略返回就必带"},
"description":{"type":"string","description":"策略的含义说明,解释该策略管控的行为及各取值的语义;为确定性字段,只要该策略返回就必带"},
"value":{"type":["string","null"],"description":"取值随策略类型不同:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;阈值型(member_invite、comment)为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE,阈值型(node_spread、online_content_copy)为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE/NOBODY,均表示不低于该角色才允许对应操作,NOBODY 表示所有人禁止;二值型(node_spread_scope):ALL_NODES=下载与传播限制对所有文档生效,PREVIEWABLE_ONLY=仅对可预览的文档(在线文档、图片视频等)生效;未知值时为 null"},
"disabledValues":{
"type":"array",
"description":"该策略当前不可设置的取值及禁用原因(与 allowedValues 互斥);为确定性字段,恒返回,无被禁取值时为空数组",
"items":{
"type":"object",
"properties":{
"value":{"type":"string","description":"被禁档位的取值(与 value 同一值域)"},
"reason":{"type":["string","null"],"description":"服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null"}
},
"required":["value"],
"additionalProperties":true
}
},
"allowedValues":{"type":["array","null"],"items":{"type":"string"},"description":"该策略当前可设置的取值(与 value 同一值域),未下发时为 null"}
},
"required":["code","name","description","disabledValues"],
"additionalProperties":true
}
}
},
"required":["docUrl","nodeId","shareScope","policies"],
"additionalProperties":true
}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_permission_setting"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
UseWhen: []string{"查看节点权限模式/分享范围/水印等权限策略配置时"},
AvoidWhen: []string{
"查协作者清单用 permission list",
"查可申请角色与审批人用 permission apply-info",
},
Examples: []string{"dws drive permission get-setting --node <ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
},
},
})
drivePermGetSettingCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
@@ -2429,7 +2550,7 @@ func newDriveCommand() *cobra.Command {
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd} {
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
@@ -2661,7 +2782,7 @@ func newDriveCommand() *cobra.Command {
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
// --node 隐藏别名(保持与迁移前 doc 命令一致)
driveNodeAliasCmds := []*cobra.Command{
@@ -0,0 +1,237 @@
// 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.
package helpers
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
)
// ── drive permission get-setting:跨产品路由与 --node 别名归一化 ──
func TestCrossPlatformCoverageDrivePermissionGetSettingRoutesToDrive(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting", "--node", "node-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "get_permission_setting" {
t.Fatalf("call = %#v", call)
}
if len(call.args) != 1 || call.args["nodeId"] != "node-1" {
t.Fatalf("args = %#v, want only nodeId=node-1", call.args)
}
}
func TestCrossPlatformCoverageDrivePermissionGetSettingHiddenAliases(t *testing.T) {
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting", "--"+alias, "node-alias")
if err != nil {
t.Fatalf("alias --%s: %v", alias, err)
}
if len(caller.calls) != 1 {
t.Fatalf("alias --%s calls = %#v, want exactly one", alias, caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "get_permission_setting" {
t.Fatalf("alias --%s call = %#v", alias, call)
}
if call.args["nodeId"] != "node-alias" {
t.Fatalf("alias --%s args = %#v, want nodeId=node-alias", alias, call.args)
}
}
}
func TestCrossPlatformCoverageDrivePermissionGetSettingRequiresNode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting")
if err == nil || !strings.Contains(err.Error(), "flag --node is required") {
t.Fatalf("err = %v, want flag --node is required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none before required-flag validation", caller.calls)
}
}
// ── drive permission get-setting:ResultSpec 返回值契约 ──
func TestCrossPlatformCoverageDrivePermissionGetSettingResultContract(t *testing.T) {
drive := newDriveCommand()
leaf, _, err := drive.Find([]string{"permission", "get-setting"})
if err != nil || leaf == nil {
t.Fatalf("find drive permission get-setting: command=%v err=%v", leaf, err)
}
final, ok := contractfinal.RuntimeContractFinal(leaf)
if !ok || final.Identity == nil || final.Identity.CanonicalPath != "drive.get_permission_setting" {
t.Fatalf("get-setting ContractFinal identity = %#v, found = %v", final.Identity, ok)
}
if final.Result == nil {
t.Fatal("get-setting final Result is nil")
}
wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) {
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes)
}
var root map[string]any
if err := json.Unmarshal(final.Result.DataSchema, &root); err != nil {
t.Fatalf("result data_schema is not JSON: %v\n%s", err, final.Result.DataSchema)
}
assertSchemaRequired(t, root, "docUrl", "nodeId", "shareScope", "policies")
properties := resultSchemaProperties(t, final.Result.DataSchema)
if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, []string{"docUrl", "nodeId", "permissionMode", "policies", "shareScope"}) {
t.Fatalf("result properties = %#v", got)
}
permissionMode, ok := properties["permissionMode"].(map[string]any)
if !ok {
t.Fatalf("permissionMode = %#v, want schema object", properties["permissionMode"])
}
if got := schemaEnumValues(t, permissionMode); !reflect.DeepEqual(got, []string{"INHERITED", "INDEPENDENT", "<null>"}) {
t.Fatalf("permissionMode enum = %#v", got)
}
if types, ok := permissionMode["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
t.Fatalf("permissionMode type = %#v, want [string null]", permissionMode["type"])
}
shareScope := schemaObjectProperty(t, properties, "shareScope")
shareScopeProperties := schemaProperties(t, shareScope)
if got := sortedContractSchemaKeys(shareScopeProperties); !reflect.DeepEqual(got, []string{"canRecommend", "canSearch", "defaultRole", "linkShare", "partnerIncluded", "visibility"}) {
t.Fatalf("shareScope properties = %#v", got)
}
if _, ok := shareScope["required"]; ok {
t.Fatalf("shareScope required = %#v, want none (linkShare is only returned when link sharing is configured)", shareScope["required"])
}
if got := schemaEnumValues(t, shareScopeProperties["visibility"].(map[string]any)); !reflect.DeepEqual(got, []string{"PRIVATE", "ORGANIZATION", "PUBLIC", "<null>"}) {
t.Fatalf("visibility enum = %#v", got)
}
if got := schemaEnumValues(t, shareScopeProperties["defaultRole"].(map[string]any)); !reflect.DeepEqual(got, []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER", "<null>"}) {
t.Fatalf("defaultRole enum = %#v", got)
}
linkShareProperties := schemaProperties(t, schemaObjectProperty(t, shareScopeProperties, "linkShare"))
if got := sortedContractSchemaKeys(linkShareProperties); !reflect.DeepEqual(got, []string{"expireAt", "expireDays", "forCurrentNode", "requirePassword"}) {
t.Fatalf("linkShare properties = %#v", got)
}
policies, ok := properties["policies"].(map[string]any)
if !ok || policies["type"] != "array" {
t.Fatalf("policies = %#v, want array schema", properties["policies"])
}
policiesDescription, _ := policies["description"].(string)
for _, fragment := range []string{"未下发或不受支持的策略不会返回", "node_spread_scope 仅文件夹类节点返回", "两者互斥"} {
if !strings.Contains(policiesDescription, fragment) {
t.Fatalf("policies description missing %q: %s", fragment, policiesDescription)
}
}
items, ok := policies["items"].(map[string]any)
if !ok {
t.Fatalf("policies items = %#v", policies["items"])
}
assertSchemaRequired(t, items, "code", "name", "description", "disabledValues")
itemProperties := schemaProperties(t, items)
if got := sortedContractSchemaKeys(itemProperties); !reflect.DeepEqual(got, []string{"allowedValues", "code", "description", "disabledValues", "name", "value"}) {
t.Fatalf("policy item properties = %#v", got)
}
code, ok := itemProperties["code"].(map[string]any)
if !ok {
t.Fatalf("code = %#v", itemProperties["code"])
}
if got := schemaEnumValues(t, code); !reflect.DeepEqual(got, []string{
"external_share", "external_share_manager_only", "member_invite", "member_invite_org_only",
"comment", "permission_apply", "external_permission_apply", "watermark", "node_spread",
"online_content_copy", "node_move_forbidden", "node_spread_scope",
}) {
t.Fatalf("code enum = %#v", got)
}
value, ok := itemProperties["value"].(map[string]any)
if !ok {
t.Fatalf("value = %#v", itemProperties["value"])
}
valueDescription, _ := value["description"].(string)
for _, fragment := range []string{"ENABLED/DISABLED", "READER_AND_ABOVE", "NOBODY", "ALL_NODES", "PREVIEWABLE_ONLY", "不低于该角色才允许", "所有人禁止", "限制对所有文档生效", "仅对可预览的文档"} {
if !strings.Contains(valueDescription, fragment) {
t.Fatalf("value description missing %q: %s", fragment, valueDescription)
}
}
for _, field := range []string{"name", "description"} {
entry, ok := itemProperties[field].(map[string]any)
if !ok || entry["type"] != "string" {
t.Fatalf("policy item %s = %#v, want string schema", field, itemProperties[field])
}
entryDescription, _ := entry["description"].(string)
if !strings.Contains(entryDescription, "确定性字段") || !strings.Contains(entryDescription, "只要该策略返回就必带") {
t.Fatalf("policy item %s description = %q", field, entryDescription)
}
}
disabledValues, ok := itemProperties["disabledValues"].(map[string]any)
if !ok || disabledValues["type"] != "array" {
t.Fatalf("disabledValues = %#v, want array schema", itemProperties["disabledValues"])
}
disabledValuesDescription, _ := disabledValues["description"].(string)
for _, fragment := range []string{"与 allowedValues 互斥", "恒返回", "空数组"} {
if !strings.Contains(disabledValuesDescription, fragment) {
t.Fatalf("disabledValues description missing %q: %s", fragment, disabledValuesDescription)
}
}
disabledItems, ok := disabledValues["items"].(map[string]any)
if !ok {
t.Fatalf("disabledValues items = %#v", disabledValues["items"])
}
assertSchemaRequired(t, disabledItems, "value")
disabledItemProperties := schemaProperties(t, disabledItems)
if got := sortedContractSchemaKeys(disabledItemProperties); !reflect.DeepEqual(got, []string{"reason", "value"}) {
t.Fatalf("disabledValues item properties = %#v", got)
}
if disabledValue, ok := disabledItemProperties["value"].(map[string]any); !ok || disabledValue["type"] != "string" {
t.Fatalf("disabledValues value = %#v, want string schema", disabledItemProperties["value"])
}
reason, ok := disabledItemProperties["reason"].(map[string]any)
if !ok {
t.Fatalf("disabledValues reason = %#v", disabledItemProperties["reason"])
}
if types, ok := reason["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
t.Fatalf("disabledValues reason type = %#v, want [string null]", reason["type"])
}
allowedValues, ok := itemProperties["allowedValues"].(map[string]any)
if !ok {
t.Fatalf("allowedValues = %#v", itemProperties["allowedValues"])
}
if allowedValuesDescription, _ := allowedValues["description"].(string); !strings.Contains(allowedValuesDescription, "与 value 同一值域") {
t.Fatalf("allowedValues description = %q", allowedValues["description"])
}
}
func schemaEnumValues(t *testing.T, property map[string]any) []string {
t.Helper()
raw, ok := property["enum"].([]any)
if !ok {
t.Fatalf("schema enum = %#v, want array", property["enum"])
}
values := make([]string, 0, len(raw))
for _, value := range raw {
switch typed := value.(type) {
case string:
values = append(values, typed)
case nil:
values = append(values, "<null>")
default:
t.Fatalf("schema enum value = %#v, want string or null", value)
}
}
return values
}
File diff suppressed because it is too large Load Diff
+503
View File
@@ -0,0 +1,503 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func withEduAppCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func runEduApp(t *testing.T, args ...string) error {
t.Helper()
cmd := newEduAppCommand()
cmd.SetArgs(args)
return cmd.Execute()
}
// TestEduAppHappyPathsFullFlags exercises each leaf command with every flag
// populated, so all optional-field branches and the dispatch line are covered.
func TestCrossPlatformCoverageEduAppHappyPathsFullFlags(t *testing.T) {
cases := [][]string{
{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"},
{"task", "publish-list", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_HOMEWORK,EDU_NOTICE"},
{"task", "all-list", "--biz-id", "1", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_CARD"},
{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`, "--query-all", "--cursor", "c", "--limit", "10", "--task-sources", "EDU_SR"},
{"report", "get", "--ids", "1001,1002"},
{"report", "by-teacher", "--page", "1", "--limit", "20", "--status", "1"},
{"report", "by-class", "--report-id", "1001", "--class-id", "12345", "--student-ids", "u1,u2"},
{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1", "--page", "1", "--limit", "20"},
{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"},
{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1", "--device-id", "d1", "--parent-name", "张三", "--update-sign"},
{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假", "--title", "放假",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--type", "SCHOOL", "--scope", "ALL", "--target-role", "guardian", "--is-signed", "true",
"--photo", "p", "--media", "m", "--audio", "a", "--send-ding", "--scheduled-release", "2026-07-29",
"--notice-deadline", "100", "--notice-deadline-open", "true", "--notice-deadline-setting", "s",
"--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "list-by-teacher", "--class-id", "c", "--type", "SCHOOL", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "get", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c", "--status", "CONFIRMED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"},
{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--content", "新内容", "--should-send-update-msg"},
{"card", "end", "--card-id", "1"},
{"card", "list", "--status", "UNFINISH", "--class-id", "5", "--page", "1", "--limit", "10"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--finish", "--page", "1", "--limit", "20"},
{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1", "--target-role", "guardian", "--student-id", "stu1"},
{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "期末三好学生", "--user-name", "张三",
"--title", "三好学生", "--unit-name", "实验小学", "--tag", "三好", "--photo", "p", "--publish-time", "2026-07-29",
"--biz-code", "bc", "--biz-category", "cat", "--msg-type", "mt", "--template-url", "tpl",
"--class-ids", "1,2", "--select-class", `[{"classId":"1"}]`, "--attributes", `{"k":"v"}`},
{"diploma", "read", "--diploma-id", "1", "--class-id", "c", "--student-id", "u1", "--user-name", "张三"},
{"diploma", "list-by-teacher", "--page", "1", "--limit", "20", "--status", "PUBLISHED", "--tag", "三好", "--user-name", "u"},
{"diploma", "get", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "statistics", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "detail", "--diploma-id", "1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "1", "--limit", "20", "--user-name", "张三"},
{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "delete", "--diploma-id", "1", "--user-name", "张三"},
{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习",
"--hw-title", "数学作业", "--hw-photo", "p", "--hw-media", "m", "--hw-video", "v",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--feedback", "fb", "--hw-deadline", "100", "--hw-deadline-open", "true", "--hw-deadline-setting", "s",
"--submit-types", "TEXT,PHOTO", "--hw-type", "HOMEWORK", "--target-role", "guardian", "--publish-type", "NOW",
"--biz-code", "bc", "--scheduled-release", "2026-07-29", "--task-plan-duration", "5", "--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"homework", "delete", "--homework-id", "1", "--user-name", "张三"},
{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "2", "--student-id", "u1", "--class-id", "c",
"--content", "已完成", "--photo", "p", "--media", "m", "--video", "v", "--user-name", "张三"},
{"homework", "get", "--homework-id", "1", "--user-name", "张三"},
{"homework", "class-by-homework", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "class-detail", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--status", "FINISHED", "--page", "1", "--page-size", "20"},
{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-teacher", "--class-id", "c", "--type", "HOMEWORK", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1", "--homework-id", "2",
"--student-id", "u1", "--photo", "p", "--video", "v", "--media", "m", "--user-name", "张三"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
// TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 /
// page-size<=0 / the >0 else arms) that the positive-value happy paths skip.
func TestCrossPlatformCoverageEduAppZeroPagination(t *testing.T) {
cases := [][]string{
{"report", "by-teacher", "--page", "0", "--limit", "0"},
{"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"},
{"notice", "list-by-teacher", "--page", "0", "--page-size", "0"},
{"notice", "confirm-status", "--notice-id", "1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"card", "list", "--status", "FINISH", "--page", "0", "--limit", "0"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--page", "0", "--limit", "0"},
{"diploma", "list-by-teacher", "--page", "0", "--limit", "0"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--limit", "0"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--page", "0", "--page-size", "0"},
{"homework", "list-by-teacher", "--page", "0", "--page-size", "0"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduAppErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"summary-list missing class-id", []string{"message", "summary-list", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "class-id"},
{"summary-list non-int class-id", []string{"message", "summary-list", "--class-id", "abc", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "整数"},
{"summary-list missing cid", []string{"message", "summary-list", "--class-id", "1", "--target-role", "guardian", "--status", "0"}, "cid"},
{"summary-list missing target-role", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--status", "0"}, "target-role"},
{"summary-list missing status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian"}, "status"},
{"summary-list non-int status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "x"}, "status"},
{"all-list missing biz-id", []string{"task", "all-list"}, "biz-id"},
{"student-list missing students", []string{"task", "student-list"}, "students"},
{"student-list bad json", []string{"task", "student-list", "--students", "{"}, "JSON"},
{"report get missing ids", []string{"report", "get"}, "ids"},
{"report get non-int ids", []string{"report", "get", "--ids", "abc"}, "整数"},
{"report by-class missing report-id", []string{"report", "by-class", "--class-id", "c"}, "report-id"},
{"report by-class non-int report-id", []string{"report", "by-class", "--report-id", "x", "--class-id", "c"}, "整数"},
{"report by-class missing class-id", []string{"report", "by-class", "--report-id", "1"}, "class-id"},
{"report by-student-list missing class-id", []string{"report", "by-student-list", "--student-id", "u1"}, "class-id"},
{"report by-student-list missing student-id", []string{"report", "by-student-list", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing report-id", []string{"report", "by-student-detail", "--student-id", "u1", "--class-id", "c"}, "report-id"},
{"report by-student-detail non-int report-id", []string{"report", "by-student-detail", "--report-id", "x", "--student-id", "u1", "--class-id", "c"}, "整数"},
{"report by-student-detail missing student-id", []string{"report", "by-student-detail", "--report-id", "1", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing class-id", []string{"report", "by-student-detail", "--report-id", "1", "--student-id", "u1"}, "class-id"},
{"notice confirm missing notice-id", []string{"notice", "confirm", "--student-id", "u1"}, "notice-id"},
{"notice confirm missing student-id", []string{"notice", "confirm", "--notice-id", "n1"}, "student-id"},
{"notice create missing identifer", []string{"notice", "create", "--content", "c"}, "identifer"},
{"notice create missing content", []string{"notice", "create", "--identifer", "x"}, "content"},
{"notice create bad selected-students", []string{"notice", "create", "--identifer", "x", "--content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"notice create bad attributes", []string{"notice", "create", "--identifer", "x", "--content", "c", "--attributes", "{"}, "attributes"},
{"notice delete missing notice-id", []string{"notice", "delete"}, "notice-id"},
{"notice delete non-int notice-id", []string{"notice", "delete", "--notice-id", "x"}, "整数"},
{"notice get missing notice-id", []string{"notice", "get"}, "notice-id"},
{"notice confirm-status missing notice-id", []string{"notice", "confirm-status", "--class-id", "c"}, "notice-id"},
{"notice confirm-status missing class-id", []string{"notice", "confirm-status", "--notice-id", "1"}, "class-id"},
{"notice list-by-student missing student-id", []string{"notice", "list-by-student", "--class-id", "c"}, "student-id"},
{"notice list-by-student missing class-id", []string{"notice", "list-by-student", "--student-id", "u1"}, "class-id"},
{"circle posts missing class-id", []string{"circle", "posts", "--student-id", "u1", "--target-role", "guardian"}, "class-id"},
{"circle posts missing student-id", []string{"circle", "posts", "--class-id", "c", "--target-role", "guardian"}, "student-id"},
{"circle posts missing target-role", []string{"circle", "posts", "--class-id", "c", "--student-id", "u1"}, "target-role"},
{"card update missing card-id", []string{"card", "update", "--identifier", "i", "--title", "t"}, "card-id"},
{"card update missing identifier", []string{"card", "update", "--card-id", "1", "--title", "t"}, "identifier"},
{"card update no title no content", []string{"card", "update", "--card-id", "1", "--identifier", "i"}, "至少传一个"},
{"card end missing card-id", []string{"card", "end"}, "card-id"},
{"card list missing status", []string{"card", "list"}, "status"},
{"card list invalid status", []string{"card", "list", "--status", "OTHER"}, "FINISH"},
{"card user-statistic missing card-id", []string{"card", "user-statistic", "--task-code", "c", "--class-id", "c"}, "card-id"},
{"card user-statistic missing task-code", []string{"card", "user-statistic", "--card-id", "1", "--class-id", "c"}, "task-code"},
{"card user-statistic missing class-id", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "c"}, "class-id"},
{"card finish-info missing card-id", []string{"card", "finish-info", "--card-biz-id", "b"}, "card-id"},
{"card finish-info missing card-biz-id", []string{"card", "finish-info", "--card-id", "1"}, "card-biz-id"},
{"card finish-info invalid target-role", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "b", "--target-role", "boss"}, "target-role"},
{"diploma create missing identifier", []string{"diploma", "create", "--content", "c", "--user-name", "u"}, "identifier"},
{"diploma create missing content", []string{"diploma", "create", "--identifier", "i", "--user-name", "u"}, "content"},
{"diploma create missing user-name", []string{"diploma", "create", "--identifier", "i", "--content", "c"}, "user-name"},
{"diploma create bad select-class", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--select-class", "{"}, "select-class"},
{"diploma create bad attributes", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--attributes", "{"}, "attributes"},
{"diploma read missing diploma-id", []string{"diploma", "read"}, "diploma-id"},
{"diploma get missing diploma-id", []string{"diploma", "get"}, "diploma-id"},
{"diploma statistics missing diploma-id", []string{"diploma", "statistics"}, "diploma-id"},
{"diploma detail missing diploma-id", []string{"diploma", "detail"}, "diploma-id"},
{"diploma list-by-student missing student-id", []string{"diploma", "list-by-student", "--class-id", "c"}, "student-id"},
{"diploma list-by-student missing class-id", []string{"diploma", "list-by-student", "--student-id", "u1"}, "class-id"},
{"diploma student-detail missing diploma-id", []string{"diploma", "student-detail", "--student-id", "u1", "--class-id", "c"}, "diploma-id"},
{"diploma student-detail missing student-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--class-id", "c"}, "student-id"},
{"diploma student-detail missing class-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1"}, "class-id"},
{"diploma delete missing diploma-id", []string{"diploma", "delete"}, "diploma-id"},
{"homework create missing identifier", []string{"homework", "create", "--hw-content", "c"}, "identifier"},
{"homework create missing hw-content", []string{"homework", "create", "--identifier", "i"}, "hw-content"},
{"homework create bad hw-deadline", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--hw-deadline", "x"}, "hw-deadline"},
{"homework create bad task-plan-duration", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--task-plan-duration", "x"}, "task-plan-duration"},
{"homework create bad selected-students", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"homework create bad attributes", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--attributes", "{"}, "attributes"},
{"homework delete missing homework-id", []string{"homework", "delete"}, "homework-id"},
{"homework submit missing detail-id", []string{"homework", "submit"}, "hw-content-detail-id"},
{"homework submit bad homework-id", []string{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "x"}, "homework-id"},
{"homework get missing homework-id", []string{"homework", "get"}, "homework-id"},
{"homework class-by-homework missing homework-id", []string{"homework", "class-by-homework"}, "homework-id"},
{"homework class-detail missing homework-id", []string{"homework", "class-detail", "--class-id", "c", "--user-name", "u"}, "homework-id"},
{"homework class-detail missing class-id", []string{"homework", "class-detail", "--homework-id", "1", "--user-name", "u"}, "class-id"},
{"homework class-detail missing user-name", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c"}, "user-name"},
{"homework submit-statistics missing homework-id", []string{"homework", "submit-statistics", "--class-id", "c"}, "homework-id"},
{"homework submit-statistics missing class-id", []string{"homework", "submit-statistics", "--homework-id", "1"}, "class-id"},
{"homework list-by-student missing student-id", []string{"homework", "list-by-student", "--class-id", "c", "--user-name", "u"}, "student-id"},
{"homework list-by-student missing class-id", []string{"homework", "list-by-student", "--student-id", "u1", "--user-name", "u"}, "class-id"},
{"homework list-by-student missing user-name", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c"}, "user-name"},
{"homework student-detail missing homework-id", []string{"homework", "student-detail", "--student-id", "u1", "--class-id", "c"}, "homework-id"},
{"homework student-detail missing student-id", []string{"homework", "student-detail", "--homework-id", "1", "--class-id", "c"}, "student-id"},
{"homework student-detail missing class-id", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1"}, "class-id"},
{"homework create-comment missing comment", []string{"homework", "create-comment", "--hw-content-detail-id", "1"}, "comment"},
{"homework create-comment missing detail-id", []string{"homework", "create-comment", "--comment", "cm"}, "hw-content-detail-id"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduAppCaller(t)
err := runEduApp(t, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduAppParseHelpers(t *testing.T) {
if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduAppParseCSV = %#v", got)
}
ids, err := eduAppParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduAppParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduAppParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduAppParseIntCSV invalid = nil error")
}
}
func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) {
t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"message", "summary-list", "--class-id", "100", "--cid", "cidxxx", "--target-role", "guardian", "--status", "1"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_ai_message_summary_list" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_ai_message_summary_list")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != int64(100) {
t.Fatalf("classId = %v, want 100", input["classId"])
}
if input["cid"] != "cidxxx" {
t.Fatalf("cid = %v, want %q", input["cid"], "cidxxx")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
if input["status"] != int64(1) {
t.Fatalf("status = %v, want 1", input["status"])
}
})
t.Run("report get dispatches get_report", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"report", "get", "--ids", "1001,1002"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_report" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_report")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
ids, ok := input["schoolReportIdList"].([]int64)
if !ok || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 {
t.Fatalf("schoolReportIdList = %v, want [1001 1002]", input["schoolReportIdList"])
}
})
t.Run("circle posts dispatches query_student_circle_posts", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"circle", "posts", "--class-id", "12345", "--student-id", "stu1", "--target-role", "guardian"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "query_student_circle_posts" {
t.Fatalf("tool = %q, want %q", caller.tool, "query_student_circle_posts")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != "12345" {
t.Fatalf("classId = %v, want %q", input["classId"], "12345")
}
if input["studentId"] != "stu1" {
t.Fatalf("studentId = %v, want %q", input["studentId"], "stu1")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
})
t.Run("card end dispatches end_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "end", "--card-id", "999"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "end_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "end_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(999) {
t.Fatalf("cardId = %v, want 999", input["cardId"])
}
})
t.Run("card update dispatches update_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "update", "--card-id", "77", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--should-send-update-msg"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "update_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "update_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(77) {
t.Fatalf("cardId = %v, want 77", input["cardId"])
}
if input["identifier"] != "org1-staff1-uuid" {
t.Fatalf("identifier = %v, want %q", input["identifier"], "org1-staff1-uuid")
}
if input["title"] != "新标题" {
t.Fatalf("title = %v, want %q", input["title"], "新标题")
}
if input["shouldSendUpdateMsg"] != true {
t.Fatalf("shouldSendUpdateMsg = %v, want true", input["shouldSendUpdateMsg"])
}
})
}
// newEduAppConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduAppConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduAppCommand())
return root
}
// TestCrossPlatformCoverageEduAppDestructiveConfirmGate 对 edu-app 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduAppDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"notice delete",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345"},
"delete_notice",
map[string]any{"noticeId": int64(12345)},
},
{
"notice delete with user-name",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
"delete_notice",
map[string]any{"noticeId": int64(12345), "userName": "张三"},
},
{
"homework delete",
[]string{"edu-app", "homework", "delete", "--homework-id", "12345"},
"delete_homework",
map[string]any{"homeworkId": int64(12345)},
},
{
"diploma delete",
[]string{"edu-app", "diploma", "delete", "--diploma-id", "12345"},
"delete_diploma",
map[string]any{"diplomaId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-app" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-app")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
File diff suppressed because it is too large Load Diff
+672
View File
@@ -0,0 +1,672 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func newTestEduContactRoot() *cobra.Command {
return newEduContactCommand()
}
// ──────────────────────────────────────────────────────────
// 命令注册测试 — 验证所有子命令路径是否正确注册
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageEduContactCommandTree(t *testing.T) {
root := newTestEduContactRoot()
paths := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "class-list"},
// class — 原有
{"class", "detail"},
{"class", "students"},
{"class", "teachers"},
{"class", "same-name"},
{"class", "user-role"},
{"class", "search-by-name"},
{"class", "headmaster"},
// class — 新增
{"class", "search-by-teacher"},
{"class", "add-student"},
{"class", "add-teachers"},
{"class", "add-unofficial-student"},
{"class", "delete-students"},
{"class", "delete-teacher"},
{"class", "modify-student-info"},
{"class", "move-student"},
{"class", "update-info"},
{"class", "update-student"},
{"class", "update-student-mobile"},
{"class", "update-student-number"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course"},
}
for _, path := range paths {
if _, _, err := root.Find(path); err != nil {
t.Errorf("command path %v not found: %v", path, err)
}
}
}
// ──────────────────────────────────────────────────────────
// 参数校验测试 — 验证必填参数缺失时返回错误
// ──────────────────────────────────────────────────────────
func executeCommand(root *cobra.Command, args ...string) error {
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageClassSearchByTeacher_MissingName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "search-by-teacher")
if err == nil {
t.Fatal("expected error for missing --name, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingTeacherUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_InvalidIsAdviser(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3")
if err == nil {
t.Fatal("expected error for invalid --is-adviser, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingOriginClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --origin-class-id, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentMobile_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --mobile, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingStaffIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-staff-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingStudentUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --student-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch")
if err == nil {
t.Fatal("expected error for invalid --patriarchs JSON, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --student-number, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--nick", "火箭班")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群")
if err == nil {
t.Fatal("expected error for --group-name without --conversation-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteTeacher_MissingTeacherUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing both --nick and --patriarch-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1")
if err == nil {
t.Fatal("expected error for --patriarch-user-id without --relation, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingStudentName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000")
if err == nil {
t.Fatal("expected error for missing --student-name, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三")
if err == nil {
t.Fatal("expected error for missing mobile (student or parent), got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_InvalidMotherJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json")
if err == nil {
t.Fatal("expected error for invalid --mother JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course")
if err == nil {
t.Fatal("expected error for missing --teacher-class-infos, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_InvalidJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json")
if err == nil {
t.Fatal("expected error for invalid --teacher-class-infos JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_EmptyArray(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]")
if err == nil {
t.Fatal("expected error for empty --teacher-class-infos array, got nil")
}
}
// ──────────────────────────────────────────────────────────
// Happy-path 测试 — 每个 leaf 命令的成功分支(经由 dry-run caller)
// 以及所有可选字段分支,用于把 changed-code 覆盖率补到 100%。
// ──────────────────────────────────────────────────────────
// withEduContactCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactHappyPaths(t *testing.T) {
withEduContactCaller(t)
cases := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "stats", "--statistics-type", "1"},
{"school", "class-list"},
// class — 读操作
{"class", "detail", "--dept-id", "123"},
{"class", "students", "--dept-id", "123"},
{"class", "teachers", "--dept-id", "123"},
{"class", "same-name", "--dept-id", "123"},
{"class", "user-role", "--dept-id", "123"},
{"class", "search-by-name", "--query-type", "student", "--name", "张三"},
{"class", "headmaster", "--class-name", "一年级1班"},
{"class", "search-by-teacher", "--name", "张老师"},
// class — update-student 各分支
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1",
"--student-name", "张三", "--student-number", "S1", "--append-patriarch",
"--patriarchs", `[{"userId":"uid1","relation":"F"}]`},
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"},
// class — add-student 各手机号来源分支
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--student-mobile", "13800138000", "--student-user-id", "u1",
"--student-number", "S1", "--virtual-account-id", "v1"},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--mother", `{"mobile":"13800138000","relation":"M"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--father", `{"mobile":"13900139000","relation":"F"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--other-patriarchs", `[{"mobile":"13700137000","relation":"O"}]`},
// class — modify-student-info 两个分支
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "张三"},
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1",
"--patriarch-user-id", "p1", "--relation", "父亲"},
// class — delete-teacher
{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"},
// class — update-info 三个可选分支
{"class", "update-info", "--class-id", "123", "--nick", "火箭班"},
{"class", "update-info", "--class-id", "123", "--expected-student-num", "45"},
{"class", "update-info", "--class-id", "123", "--group-name", "家长群", "--conversation-id", "cid1"},
// class — update-student-number
{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S1"},
// class — add-unofficial-student
{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"},
// class — delete-students
{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"},
// class — update-student-mobile
{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"},
// class — move-student
{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"},
// class — add-teachers 默认/班主任
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"},
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1", "--is-adviser", "1"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"c1","courseName":"语文"}]`},
}
for _, args := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, args...); err != nil {
t.Errorf("happy path %v returned error: %v", args, err)
}
}
}
// TestEduContactErrorPathsRemaining covers validation branches not exercised by
// the existing error tests, ensuring 100% changed-code coverage.
func TestCrossPlatformCoverageEduContactErrorPathsRemaining(t *testing.T) {
cases := []struct {
name string
args []string
}{
// eduRequiredIntFlag:空值 + 非整数
{"stats-invalid-type", []string{"school", "stats", "--statistics-type", "abc"}},
{"detail-missing-dept", []string{"class", "detail"}},
{"detail-invalid-dept", []string{"class", "detail", "--dept-id", "abc"}},
{"students-missing-dept", []string{"class", "students"}},
{"teachers-missing-dept", []string{"class", "teachers"}},
{"samename-missing-dept", []string{"class", "same-name"}},
{"userrole-missing-dept", []string{"class", "user-role"}},
{"searchbyname-missing-querytype", []string{"class", "search-by-name", "--name", "张三"}},
{"searchbyname-missing-name", []string{"class", "search-by-name", "--query-type", "student"}},
{"headmaster-missing-classname", []string{"class", "headmaster"}},
// update-student
{"updatestudent-missing-classid", []string{"class", "update-student", "--student-user-id", "u1"}},
{"updatestudent-missing-userid", []string{"class", "update-student", "--class-id", "123"}},
{"updatestudent-invalid-patriarchs", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1", "--patriarchs", "not-json"}},
// add-student
{"addstudent-missing-dept", []string{"class", "add-student", "--student-name", "张三"}},
{"addstudent-missing-name", []string{"class", "add-student", "--dept-id", "123"}},
{"addstudent-invalid-father", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--father", "not-json"}},
{"addstudent-invalid-other", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--other-patriarchs", "not-json"}},
{"addstudent-no-mobile", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三"}},
// modify-student-info
{"modify-missing-dept", []string{"class", "modify-student-info", "--target-user-id", "u1", "--nick", "张三"}},
{"modify-missing-target", []string{"class", "modify-student-info", "--dept-id", "123", "--nick", "张三"}},
// delete-teacher
{"deleteteacher-missing-classid", []string{"class", "delete-teacher", "--teacher-user-id", "u1"}},
// update-info
{"updateinfo-missing-classid", []string{"class", "update-info", "--nick", "火箭班"}},
{"updateinfo-invalid-expected", []string{"class", "update-info", "--class-id", "123", "--expected-student-num", "abc"}},
// update-student-number
{"usn-missing-classid", []string{"class", "update-student-number", "--student-user-id", "u1", "--student-number", "S1"}},
{"usn-missing-userid", []string{"class", "update-student-number", "--class-id", "123", "--student-number", "S1"}},
// add-unofficial-student
{"unofficial-missing-dept", []string{"class", "add-unofficial-student", "--student-staff-ids", "s1"}},
{"unofficial-empty-staffids", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", ",,"}},
// delete-students
{"deletestudents-missing-userids", []string{"class", "delete-students", "--dept-id", "123"}},
{"deletestudents-empty-userids", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", ",,"}},
// update-student-mobile
{"usm-missing-dept", []string{"class", "update-student-mobile", "--student-user-id", "u1", "--mobile", "13800138000"}},
{"usm-missing-userid", []string{"class", "update-student-mobile", "--dept-id", "123", "--mobile", "13800138000"}},
// move-student
{"move-missing-target", []string{"class", "move-student", "--student-user-ids", "u1", "--origin-class-id", "123"}},
{"move-empty-userids", []string{"class", "move-student", "--origin-class-id", "123", "--target-class-id", "456", "--student-user-ids", ",,"}},
// add-teachers
{"addteachers-empty-userids", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", ",,"}},
{"addteachers-too-many", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", strings.Repeat("u,", 51) + "u"}},
}
for _, tc := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, tc.args...); err == nil {
t.Errorf("%s: expected error, got nil", tc.name)
}
}
}
// ──────────────────────────────────────────────────────────
// Dispatch 验证测试 — 验证命令正确派发到 MCP Server
// ──────────────────────────────────────────────────────────
// withEduContactDispatchCaller installs a non-dry-run capture caller so that
// callMCPToolOnServer goes through deps.Caller.CallTool and we can verify
// the dispatched productID, tool name, and args.
func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) {
t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "detail", "--dept-id", "123"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "get_class_detail" {
t.Errorf("tool = %q, want %q", caller.tool, "get_class_detail")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(123) {
t.Errorf("input[deptId] = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
})
t.Run("class search-by-name dispatches query_class_by_guardian_name", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "search-by-name", "--query-type", "student", "--name", "张三"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "query_class_by_guardian_name" {
t.Errorf("tool = %q, want %q", caller.tool, "query_class_by_guardian_name")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["queryType"] != "student" {
t.Errorf("input[queryType] = %v, want %q", input["queryType"], "student")
}
if input["name"] != "张三" {
t.Errorf("input[name] = %v, want %q", input["name"], "张三")
}
})
t.Run("school stats dispatches statistics_school with statisticsType", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"school", "stats", "--statistics-type", "2"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "statistics_school" {
t.Errorf("tool = %q, want %q", caller.tool, "statistics_school")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["statisticsType"] != int64(2) {
t.Errorf("input[statisticsType] = %v (%T), want int64(2)", input["statisticsType"], input["statisticsType"])
}
})
t.Run("class add-teachers dispatches batch_add_class_teacher with list and isAdviser", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "add-teachers", "--dept-id", "789", "--teacher-user-ids", "t1,t2", "--is-adviser", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "batch_add_class_teacher" {
t.Errorf("tool = %q, want %q", caller.tool, "batch_add_class_teacher")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(789) {
t.Errorf("input[deptId] = %v (%T), want int64(789)", input["deptId"], input["deptId"])
}
teacherUserIds, ok := input["teacherUserIds"].([]string)
if !ok {
t.Fatalf("input[teacherUserIds] is not []string: %#v", input["teacherUserIds"])
}
if len(teacherUserIds) != 2 || teacherUserIds[0] != "t1" || teacherUserIds[1] != "t2" {
t.Errorf("input[teacherUserIds] = %v, want [t1 t2]", teacherUserIds)
}
if input["isAdviser"] != int64(1) {
t.Errorf("input[isAdviser] = %v (%T), want int64(1)", input["isAdviser"], input["isAdviser"])
}
})
t.Run("class move-student dispatches move_student with list and two int flags", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "100", "--target-class-id", "200"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "move_student" {
t.Errorf("tool = %q, want %q", caller.tool, "move_student")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
studentUserIds, ok := input["studentUserIds"].([]string)
if !ok {
t.Fatalf("input[studentUserIds] is not []string: %#v", input["studentUserIds"])
}
if len(studentUserIds) != 2 || studentUserIds[0] != "u1" || studentUserIds[1] != "u2" {
t.Errorf("input[studentUserIds] = %v, want [u1 u2]", studentUserIds)
}
if input["originClassId"] != int64(100) {
t.Errorf("input[originClassId] = %v (%T), want int64(100)", input["originClassId"], input["originClassId"])
}
if input["targetClassId"] != int64(200) {
t.Errorf("input[targetClassId] = %v (%T), want int64(200)", input["targetClassId"], input["targetClassId"])
}
})
}
// newEduContactConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduContactConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduContactCommand())
return root
}
// TestCrossPlatformCoverageEduContactDestructiveConfirmGate 对 edu-contact 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduContactDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"class delete-teacher",
[]string{"edu-contact", "class", "delete-teacher", "--class-id", "12345", "--teacher-user-id", "userId1"},
"delete_teacher",
map[string]any{"classId": int64(12345), "teacherUserId": "userId1"},
},
{
"class delete-students",
[]string{"edu-contact", "class", "delete-students", "--dept-id", "12345", "--student-user-ids", "userId1,userId2"},
"delete_students",
map[string]any{"deptId": int64(12345), "studentUserIds": []string{"userId1", "userId2"}},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
+605
View File
@@ -0,0 +1,605 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-familygroup — 家庭群管理
// 共 6 个工具,按 group(读操作)/ manage(写操作)分组
// 参考 wukong/extensions/vendors/dingtalk/eduFamilyGroup.go 迁移
// ──────────────────────────────────────────────────────────
func newEduFamilyGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-familygroup",
Selection: contract.ProductSelectionDecl{
AgentSummary: "家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制",
UseWhen: []string{
"用户要查询或管理钉钉家庭群、添加孩子、邀请家长或控制学生应用权限。",
},
AvoidWhen: []string{
"家校通讯录用 edu-contact;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。",
},
},
})
root := &cobra.Command{
Use: "edu-familygroup",
Short: "家庭群",
Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`,
Hidden: true,
RunE: groupRunE,
}
// ════════════════════════════════════════════════════════════
// group 子命令组 — 家庭群读操作
// ════════════════════════════════════════════════════════════
groupCmd := &cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE}
groupCheckExistsCmd := &cobra.Command{
Use: "check-exists",
Short: "检查家庭群是否存在",
Long: `根据传入的 uid 拉取该用户所有家庭组织,按家庭群名称匹配判断家庭群是否存在。
仅当存在同名家庭且其群会话 cid 非空时,才认为家庭群存在,返回 true,否则返回 false。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家"
dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
groupName, err := eduFamilyGroupRequiredStringFlag(cmd, "group-name")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "check_family_group_exists", map[string]any{
"input": map[string]any{"uid": uid, "groupName": groupName},
})
},
}
DeclareLeafMetadata(groupCheckExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "check_family_group_exists",
CanonicalPath: "edu-familygroup.check_family_group_exists",
CLIPath: "edu-familygroup group check-exists",
PrimaryCLIPath: "edu-familygroup group check-exists",
},
Description: "检查家庭群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "check_family_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查家庭群是否存在",
UseWhen: []string{"需要判断指定用户名下是否存在同名家庭群时"},
AvoidWhen: []string{"查询家庭成员信息用 list-children"},
Examples: []string{
"dws edu-familygroup group check-exists --uid 12345 --group-name \"小明一家\" --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "group-name", Property: "input.groupName", Required: boolPtr(true)},
},
},
})
groupListChildrenCmd := &cobra.Command{
Use: "list-children",
Short: "查询家长绑定的孩子列表",
Long: `查询当前用户(uid)作为家长身份所在家庭中的所有孩子信息(不限家庭组织),
包含孩子基本信息及关联的学生号列表。底层按 uid 读扩散并完成家长身份校验,无孩子时返回空列表。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group list-children --uid 12345
dws edu-familygroup group list-children --uid 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "listBoundChildren", map[string]any{
"input": map[string]any{"uid": uid},
})
},
}
DeclareLeafMetadata(groupListChildrenCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "listBoundChildren",
CanonicalPath: "edu-familygroup.listBoundChildren",
CLIPath: "edu-familygroup group list-children",
PrimaryCLIPath: "edu-familygroup group list-children",
},
Description: "查询家长绑定的孩子列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "listBoundChildren"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询家长绑定的孩子列表",
UseWhen: []string{"需要查询指定家长 uid 绑定的所有孩子及关联学生号信息时"},
AvoidWhen: []string{"查看家庭群是否存在用 check-exists"},
Examples: []string{
"dws edu-familygroup group list-children --uid 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// manage 子命令组 — 家庭群写操作
// ════════════════════════════════════════════════════════════
manageCmd := &cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE}
manageCreateCmd := &cobra.Command{
Use: "create",
Short: "创建家庭群",
Long: `以 uid 作为创建人创建一个新的家庭,创建家庭时会同时创建家庭群,返回结果中携带群会话 cid。
children 为 JSON 数组,每个元素包含 name(必填)、students(必填,含 corpId + staffId)、
birthday / gender / nick / avatar / period / grade / mobile(均可选)。
addGroup 为 JSON 对象,含 schoolCorpId / schoolStaffId / inviteDingtalkId / inviteId(均可选)。
新建家庭场景下无需前置家长身份校验,创建人合法性由底层校验单元完成。`,
Example: ` dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]'
dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' --source 1`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childrenRaw, _ := cmd.Flags().GetString("children")
if strings.TrimSpace(childrenRaw) == "" {
return fmt.Errorf("--children 为必填参数")
}
var children []any
if err := json.Unmarshal([]byte(childrenRaw), &children); err != nil {
return fmt.Errorf("--children 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateChildren(children); err != nil {
return err
}
input := map[string]any{"uid": uid, "children": children}
if v, _ := cmd.Flags().GetString("add-group"); strings.TrimSpace(v) != "" {
var addGroup map[string]any
if err := json.Unmarshal([]byte(v), &addGroup); err != nil {
return fmt.Errorf("--add-group 须为合法 JSON 对象: %w", err)
}
if addGroup == nil {
return fmt.Errorf("--add-group 须为 JSON 对象,不能为 null")
}
input["addGroup"] = addGroup
}
if v, _ := cmd.Flags().GetInt("source"); cmd.Flags().Changed("source") {
input["source"] = v
}
return callMCPToolOnServer("edu-familygroup", "create_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "create_family_group",
CanonicalPath: "edu-familygroup.create_family_group",
CLIPath: "edu-familygroup manage create",
PrimaryCLIPath: "edu-familygroup manage create",
},
Description: "创建家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "create_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建家庭群并同步创建家庭组织",
UseWhen: []string{"需要以指定 uid 创建新的家庭群,同时注册孩子信息并生成群会话时"},
AvoidWhen: []string{"已有家庭群要加孩子用 add-child"},
Examples: []string{
"dws edu-familygroup manage create --uid 12345 --children '[{\"name\":\"小明\",\"students\":[{\"corpId\":\"dingxxx\",\"staffId\":\"stu001\"}]}]' --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "children", Property: "input.children", Required: boolPtr(true)},
{Name: "add-group", Property: "input.addGroup"},
{Name: "source", Property: "input.source"},
},
},
})
manageInviteParentCmd := &cobra.Command{
Use: "invite-parent",
Short: "短信邀请家长加入家庭群",
Long: `通过短信向指定手机号的家长发送家庭群邀请链接,家长点击链接后加入当前家庭组织及家庭群。
返回 true 表示邀请短信已成功发送。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000
dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
mobile, err := eduFamilyGroupRequiredStringFlag(cmd, "mobile")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "invite_parent_to_familygroup", map[string]any{
"input": map[string]any{"orgId": orgID, "uid": uid, "mobile": mobile},
})
},
}
DeclareLeafMetadata(manageInviteParentCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "invite_parent_to_familygroup",
CanonicalPath: "edu-familygroup.invite_parent_to_familygroup",
CLIPath: "edu-familygroup manage invite-parent",
PrimaryCLIPath: "edu-familygroup manage invite-parent",
},
Description: "短信邀请家长加入家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "invite_parent_to_familygroup"},
},
Selection: contract.SelectionSpec{
AgentSummary: "通过短信邀请家长加入家庭群",
UseWhen: []string{"需要向指定手机号发送家庭群邀请短信时"},
AvoidWhen: []string{"添加孩子到家庭群用 add-child"},
Examples: []string{
"dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile", Required: boolPtr(true)},
},
},
})
manageAddChildCmd := &cobra.Command{
Use: "add-child",
Short: "为家庭群添加孩子",
Long: `为指定家庭群添加孩子,支持三种方式(由底层自动路由):
- 仅传 --mobile:手机号邀请链路,该手机号对应的钉钉账号被邀请加入家庭群
- 仅传 --students:直接生成学生号链路,选中学生后创建孩子并绑定关系
- 同时传 --mobile + --students:mobile 优先,走手机号邀请链路
mobile 与 students 至少传一个。
students 为 JSON 数组,每个元素含 schoolOrgId(整数)和 studentStaffId(字符串),均必填。
仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000
dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --students '[{"schoolOrgId":111,"studentStaffId":"stu001"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
name, err := eduFamilyGroupRequiredStringFlag(cmd, "name")
if err != nil {
return err
}
mobile, _ := cmd.Flags().GetString("mobile")
mobile = strings.TrimSpace(mobile)
studentsRaw, _ := cmd.Flags().GetString("students")
studentsRaw = strings.TrimSpace(studentsRaw)
if mobile == "" && studentsRaw == "" {
return fmt.Errorf("--mobile 与 --students 至少传一个")
}
input := map[string]any{"orgId": orgID, "uid": uid, "name": name}
if mobile != "" {
input["mobile"] = mobile
}
if studentsRaw != "" {
var students []any
if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil {
return fmt.Errorf("--students 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateStudents(students); err != nil {
return err
}
input["students"] = students
}
return callMCPToolOnServer("edu-familygroup", "add_child_to_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageAddChildCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "add_child_to_family_group",
CanonicalPath: "edu-familygroup.add_child_to_family_group",
CLIPath: "edu-familygroup manage add-child",
PrimaryCLIPath: "edu-familygroup manage add-child",
},
Description: "为家庭群添加孩子",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "add_child_to_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为已有家庭群添加孩子",
UseWhen: []string{"需要向已有家庭群添加新孩子(通过手机号邀请或直接绑定学生号)时"},
AvoidWhen: []string{"创建全新家庭群用 create"},
Examples: []string{
"dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "name", Property: "input.name", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile"},
{Name: "students", Property: "input.students"},
},
},
})
manageToggleAppCmd := &cobra.Command{
Use: "toggle-app",
Short: "开启或关闭学生应用权限",
Long: `为指定学生号开启或关闭应用权限。
支持的应用类型(--app-type):
- XIAOTIANDI:小天地(学生圈)
- LEARNING_VIDEO:学习视频
直接覆写权限状态,天然幂等。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true
dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type LEARNING_VIDEO --open false`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childStaffID, err := eduFamilyGroupRequiredStringFlag(cmd, "child-staff-id")
if err != nil {
return err
}
appType, err := eduFamilyGroupRequiredStringFlag(cmd, "app-type")
if err != nil {
return err
}
if appType != "XIAOTIANDI" && appType != "LEARNING_VIDEO" {
return fmt.Errorf("--app-type 须为 XIAOTIANDI 或 LEARNING_VIDEO")
}
openStr, err := eduFamilyGroupRequiredStringFlag(cmd, "open")
if err != nil {
return err
}
var open bool
switch strings.ToLower(openStr) {
case "true":
open = true
case "false":
open = false
default:
return fmt.Errorf("--open 须为 true 或 false")
}
return callMCPToolOnServer("edu-familygroup", "toggle_student_app", map[string]any{
"input": map[string]any{
"orgId": orgID, "uid": uid, "childStaffId": childStaffID,
"appType": appType, "open": open,
},
})
},
}
DeclareLeafMetadata(manageToggleAppCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "toggle_student_app",
CanonicalPath: "edu-familygroup.toggle_student_app",
CLIPath: "edu-familygroup manage toggle-app",
PrimaryCLIPath: "edu-familygroup manage toggle-app",
},
Description: "开启或关闭学生应用权限",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "toggle_student_app"},
},
Selection: contract.SelectionSpec{
AgentSummary: "开启或关闭学生应用权限",
UseWhen: []string{"需要为指定学生号开启或关闭小天地/学习视频应用权限时"},
AvoidWhen: []string{"管理家庭群成员用 add-child / invite-parent"},
Examples: []string{
"dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "child-staff-id", Property: "input.childStaffId", Required: boolPtr(true)},
{Name: "app-type", Property: "input.appType", Required: boolPtr(true)},
{Name: "open", Property: "input.open", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// group(读操作)flags
groupCheckExistsCmd.Flags().String("uid", "", "用户 uid(必填)")
groupCheckExistsCmd.Flags().String("group-name", "", "家庭群名称(必填)")
groupListChildrenCmd.Flags().String("uid", "", "家长 uid(必填)")
// manage(写操作)flags
manageCreateCmd.Flags().String("uid", "", "创建人 uid(必填)")
manageCreateCmd.Flags().String("children", "", "孩子信息 JSON 数组(必填)")
manageCreateCmd.Flags().String("add-group", "", "同学群信息 JSON 对象(可选)")
manageCreateCmd.Flags().Int("source", 0, "渠道来源(可选)")
manageInviteParentCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageInviteParentCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageInviteParentCmd.Flags().String("mobile", "", "被邀请家长手机号(必填)")
manageAddChildCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageAddChildCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageAddChildCmd.Flags().String("name", "", "孩子姓名(必填)")
manageAddChildCmd.Flags().String("mobile", "", "孩子手机号(可选,与 --students 至少传一个)")
manageAddChildCmd.Flags().String("students", "", "待关联学生号 JSON 数组(可选,每项含 schoolOrgId + studentStaffId)")
manageToggleAppCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageToggleAppCmd.Flags().String("uid", "", "家长 uid(必填)")
manageToggleAppCmd.Flags().String("child-staff-id", "", "孩子在家庭组织中的 staffId(必填)")
manageToggleAppCmd.Flags().String("app-type", "", "应用类型:XIAOTIANDI / LEARNING_VIDEO(必填)")
manageToggleAppCmd.Flags().String("open", "", "true=开启 / false=关闭(必填)")
groupCmd.AddCommand(groupCheckExistsCmd, groupListChildrenCmd)
manageCmd.AddCommand(manageCreateCmd, manageInviteParentCmd, manageAddChildCmd, manageToggleAppCmd)
root.AddCommand(groupCmd, manageCmd)
return root
}
// eduFamilyGroupValidateChildren validates the --children payload: the array
// must be non-empty, each child must carry a non-empty name and a non-empty
// students array, and each student must carry corpId + staffId.
func eduFamilyGroupValidateChildren(children []any) error {
if len(children) == 0 {
return fmt.Errorf("--children 不能为空数组,至少需包含一个孩子")
}
for i, c := range children {
child, ok := c.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d] 须为 JSON 对象", i)
}
name, _ := child["name"].(string)
if strings.TrimSpace(name) == "" {
return fmt.Errorf("--children[%d].name 为必填字段", i)
}
students, ok := child["students"].([]any)
if !ok || len(students) == 0 {
return fmt.Errorf("--children[%d].students 为必填字段且不能为空数组", i)
}
for j, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d].students[%d] 须为 JSON 对象", i, j)
}
corpID, _ := student["corpId"].(string)
if strings.TrimSpace(corpID) == "" {
return fmt.Errorf("--children[%d].students[%d].corpId 为必填字段", i, j)
}
staffID, _ := student["staffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--children[%d].students[%d].staffId 为必填字段", i, j)
}
}
}
return nil
}
// eduFamilyGroupValidateStudents validates the --students payload: the array
// must be non-empty and each element must carry a numeric schoolOrgId and a
// non-empty studentStaffId.
func eduFamilyGroupValidateStudents(students []any) error {
if len(students) == 0 {
return fmt.Errorf("--students 不能为空数组,至少需包含一个学生号")
}
for i, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--students[%d] 须为 JSON 对象", i)
}
switch student["schoolOrgId"].(type) {
case float64, int, int64, json.Number:
default:
return fmt.Errorf("--students[%d].schoolOrgId 为必填字段且须为整数", i)
}
staffID, _ := student["studentStaffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--students[%d].studentStaffId 为必填字段", i)
}
}
return nil
}
// eduFamilyGroupRequiredIntFlag extracts a required integer flag, returning an
// error if the flag is empty or not a valid integer.
func eduFamilyGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduFamilyGroupRequiredStringFlag extracts a required string flag.
func eduFamilyGroupRequiredStringFlag(cmd *cobra.Command, name string) (string, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return "", fmt.Errorf("--%s 为必填参数", name)
}
return v, nil
}
+318
View File
@@ -0,0 +1,318 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
)
func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"group", "check-exists", "--uid", "1", "--group-name", "小明一家"},
{"group", "list-children", "--uid", "1"},
{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", `{"schoolCorpId":"x"}`, "--source", "1"},
{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", "--mobile", "13900139000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "XIAOTIANDI", "--open", "true"},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "LEARNING_VIDEO", "--open", "false"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"check-exists missing uid", []string{"group", "check-exists", "--group-name", "x"}, "uid"},
{"check-exists non-int uid", []string{"group", "check-exists", "--uid", "abc", "--group-name", "x"}, "整数"},
{"check-exists missing group-name", []string{"group", "check-exists", "--uid", "1"}, "group-name"},
{"list-children missing uid", []string{"group", "list-children"}, "uid"},
{"create missing uid", []string{"manage", "create", "--children", "[]"}, "uid"},
{"create missing children", []string{"manage", "create", "--uid", "1"}, "children"},
{"create children bad json", []string{"manage", "create", "--uid", "1", "--children", "{"}, "JSON"},
{"create children empty array", []string{"manage", "create", "--uid", "1", "--children", "[]"}, "不能为空数组"},
{"create add-group bad json", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "{"}, "add-group"},
{"create add-group null", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "null"}, "null"},
{"invite-parent missing org-id", []string{"manage", "invite-parent", "--uid", "2", "--mobile", "138"}, "org-id"},
{"invite-parent missing uid", []string{"manage", "invite-parent", "--org-id", "1", "--mobile", "138"}, "uid"},
{"invite-parent missing mobile", []string{"manage", "invite-parent", "--org-id", "1", "--uid", "2"}, "mobile"},
{"add-child missing org-id", []string{"manage", "add-child", "--uid", "2", "--name", "x", "--mobile", "138"}, "org-id"},
{"add-child missing uid", []string{"manage", "add-child", "--org-id", "1", "--name", "x", "--mobile", "138"}, "uid"},
{"add-child missing name", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--mobile", "138"}, "name"},
{"add-child no mobile no students", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x"}, "至少传一个"},
{"add-child students bad json", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "{"}, "students"},
{"add-child students empty", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "[]"}, "不能为空数组"},
{"toggle-app missing org-id", []string{"manage", "toggle-app", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "org-id"},
{"toggle-app missing uid", []string{"manage", "toggle-app", "--org-id", "1", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "uid"},
{"toggle-app missing child-staff-id", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--app-type", "XIAOTIANDI", "--open", "true"}, "child-staff-id"},
{"toggle-app missing app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--open", "true"}, "app-type"},
{"toggle-app invalid app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "OTHER", "--open", "true"}, "XIAOTIANDI"},
{"toggle-app missing open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI"}, "open"},
{"toggle-app invalid open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "maybe"}, "true 或 false"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateChildren(t *testing.T) {
valid := []any{map[string]any{
"name": "小明",
"students": []any{map[string]any{"corpId": "c", "staffId": "s"}},
}}
if err := eduFamilyGroupValidateChildren(valid); err != nil {
t.Fatalf("valid children error = %v", err)
}
cases := []struct {
name string
children []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing name", []any{map[string]any{"students": []any{map[string]any{"corpId": "c", "staffId": "s"}}}}, "name 为必填"},
{"missing students", []any{map[string]any{"name": "x"}}, "students 为必填"},
{"student non-object", []any{map[string]any{"name": "x", "students": []any{1}}}, "须为 JSON 对象"},
{"missing corpId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"staffId": "s"}}}}, "corpId 为必填"},
{"missing staffId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"corpId": "c"}}}}, "staffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateChildren(tc.children); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateStudents(t *testing.T) {
valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}}
if err := eduFamilyGroupValidateStudents(valid); err != nil {
t.Fatalf("valid students error = %v", err)
}
cases := []struct {
name string
students []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing schoolOrgId", []any{map[string]any{"studentStaffId": "s"}}, "schoolOrgId 为必填"},
{"missing studentStaffId", []any{map[string]any{"schoolOrgId": float64(1)}}, "studentStaffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateStudents(tc.students); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) {
t.Run("check-exists", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "check-exists", "--uid", "123", "--group-name", "测试家庭"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "check_family_group_exists" {
t.Fatalf("tool = %q, want %q", caller.tool, "check_family_group_exists")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(123) {
t.Fatalf("input[\"uid\"] = %#v, want int64(123)", input["uid"])
}
if input["groupName"] != "测试家庭" {
t.Fatalf("input[\"groupName\"] = %#v, want %q", input["groupName"], "测试家庭")
}
})
t.Run("list-children", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "list-children", "--uid", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "listBoundChildren" {
t.Fatalf("tool = %q, want %q", caller.tool, "listBoundChildren")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(456) {
t.Fatalf("input[\"uid\"] = %#v, want int64(456)", input["uid"])
}
})
t.Run("create", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "create", "--uid", "789",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "create_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_family_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(789) {
t.Fatalf("input[\"uid\"] = %#v, want int64(789)", input["uid"])
}
if input["children"] == nil {
t.Fatalf("input[\"children\"] is nil, want non-nil")
}
})
t.Run("invite-parent", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "invite_parent_to_familygroup" {
t.Fatalf("tool = %q, want %q", caller.tool, "invite_parent_to_familygroup")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["orgId"] != int64(1) {
t.Fatalf("input[\"orgId\"] = %#v, want int64(1)", input["orgId"])
}
if input["uid"] != int64(2) {
t.Fatalf("input[\"uid\"] = %#v, want int64(2)", input["uid"])
}
if input["mobile"] != "13800138000" {
t.Fatalf("input[\"mobile\"] = %#v, want %q", input["mobile"], "13800138000")
}
})
t.Run("toggle-app", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "toggle-app", "--org-id", "1", "--uid", "2",
"--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "toggle_student_app" {
t.Fatalf("tool = %q, want %q", caller.tool, "toggle_student_app")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["appType"] != "XIAOTIANDI" {
t.Fatalf("input[\"appType\"] = %#v, want %q", input["appType"], "XIAOTIANDI")
}
if input["open"] != true {
t.Fatalf("input[\"open\"] = %#v, want true", input["open"])
}
})
t.Run("add-child", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "add-child", "--org-id", "12345", "--uid", "67890",
"--name", "小明", "--mobile", "13900139000",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "add_child_to_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "add_child_to_family_group")
}
want := map[string]any{
"input": map[string]any{
"orgId": int64(12345),
"uid": int64(67890),
"name": "小明",
"mobile": "13900139000",
"students": []any{
map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"},
},
},
}
if !reflect.DeepEqual(caller.args, want) {
t.Fatalf("args = %#v, want %#v", caller.args, want)
}
})
}
+846
View File
@@ -0,0 +1,846 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-group — 家校群管理
// 共 14 个工具,按 student-group / class-group / batch 分组
// ──────────────────────────────────────────────────────────
func newEduGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-group",
Selection: contract.ProductSelectionDecl{
AgentSummary: "师生群查询/创建/解散、班级群会话信息查询、批量操作",
UseWhen: []string{
"用户要查询或管理钉钉师生群、班级群会话信息,或批量检查/创建师生群。",
},
AvoidWhen: []string{
"家庭群用 edu-familygroup;家校通讯录用 edu-contact;家校应用/作业/打卡用 edu-app。",
},
},
})
root := &cobra.Command{
Use: "edu-group",
Short: "家校群",
Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`,
Hidden: true,
RunE: groupRunE,
}
// ════════════════════════════════════════════════════════════
// student-group 子命令组 — 师生群管理
// ════════════════════════════════════════════════════════════
studentGroupCmd := &cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE}
studentGroupInfoCmd := &cobra.Command{
Use: "info",
Short: "查询班级师生群信息",
Long: `查询指定班级的师生群信息。返回师生群的群会话ID(cid)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group info --dept-id 12345
dws edu-group student-group info --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_group_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_group_info",
CanonicalPath: "edu-group.get_class_group_info",
CLIPath: "edu-group student-group info",
PrimaryCLIPath: "edu-group student-group info",
},
Description: "查询班级师生群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_group_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群信息",
UseWhen: []string{"需要查询指定班级的师生群会话ID时"},
AvoidWhen: []string{"查询班级群会话详情用 student-group conversation"},
Examples: []string{
"dws edu-group student-group info --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查组织是否已创建师生群",
Long: `检查指定组织下是否已创建师生群。返回是否存在师生群(true/false)。仅限管理员角色调用。`,
Example: ` dws edu-group student-group exists --dept-id 12345
dws edu-group student-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_group_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_group_exists",
CanonicalPath: "edu-group.check_class_group_exists",
CLIPath: "edu-group student-group exists",
PrimaryCLIPath: "edu-group student-group exists",
},
Description: "检查组织是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查组织是否已创建师生群",
UseWhen: []string{"需要判断指定班级是否已创建师生群时"},
AvoidWhen: []string{"查询师生群成员用 student-group members"},
Examples: []string{
"dws edu-group student-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupMembersCmd := &cobra.Command{
Use: "members",
Short: "查询师生群成员列表",
Long: `查询指定班级师生群的所有成员userId列表。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group members --dept-id 12345
dws edu-group student-group members --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_members", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupMembersCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_members",
CanonicalPath: "edu-group.get_group_members",
CLIPath: "edu-group student-group members",
PrimaryCLIPath: "edu-group student-group members",
},
Description: "查询师生群成员列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_members"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询师生群成员列表",
UseWhen: []string{"需要查询指定班级师生群的所有成员userId列表时"},
AvoidWhen: []string{"判断用户是否在群中用 student-group is-in"},
Examples: []string{
"dws edu-group student-group members --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupIsInCmd := &cobra.Command{
Use: "is-in",
Short: "判断用户是否在师生群中",
Long: `判断当前用户是否在指定班级的师生群中。返回是否在群中(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group is-in --dept-id 12345
dws edu-group student-group is-in --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "is_in_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupIsInCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "is_in_class_group",
CanonicalPath: "edu-group.is_in_class_group",
CLIPath: "edu-group student-group is-in",
PrimaryCLIPath: "edu-group student-group is-in",
},
Description: "判断用户是否在师生群中",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "is_in_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "判断用户是否在师生群中",
UseWhen: []string{"需要判断当前用户是否在指定班级师生群中时"},
AvoidWhen: []string{"查询群成员列表用 student-group members"},
Examples: []string{
"dws edu-group student-group is-in --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "查询班级群会话详情",
Long: `查询指定班级师生群的会话详情。
返回群会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group conversation --dept-id 12345
dws edu-group student-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_conversation_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_conversation_info",
CanonicalPath: "edu-group.get_group_conversation_info",
CLIPath: "edu-group student-group conversation",
PrimaryCLIPath: "edu-group student-group conversation",
},
Description: "查询班级群会话详情",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_conversation_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群会话详情",
UseWhen: []string{"需要查询指定班级师生群的会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需群会话ID用 student-group info"},
Examples: []string{
"dws edu-group student-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupCreateCmd := &cobra.Command{
Use: "create",
Short: "创建班级师生群",
Long: `为指定班级创建师生群。自动将班级的班主任设为群主,并拉入所有老师和学生。
前提是班级必须已设置班主任。返回创建成功的群会话ID(cid)。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group create --dept-id 12345
dws edu-group student-group create --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "create_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "create_class_group",
CanonicalPath: "edu-group.create_class_group",
CLIPath: "edu-group student-group create",
PrimaryCLIPath: "edu-group student-group create",
},
Description: "创建班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定班级创建师生群",
UseWhen: []string{"需要为指定班级创建师生群,自动拉入班主任、老师和学生时"},
AvoidWhen: []string{"批量创建师生群用 batch create-student-groups"},
Examples: []string{
"dws edu-group student-group create --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupDisbandCmd := &cobra.Command{
Use: "disband",
Short: "解散班级师生群",
Long: `解散指定班级的师生群,同时删除班级与群的关联关系。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group disband --dept-id 12345
dws edu-group student-group disband --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "disband_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupDisbandCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "disband_class_group",
CanonicalPath: "edu-group.disband_class_group",
CLIPath: "edu-group student-group disband",
PrimaryCLIPath: "edu-group student-group disband",
},
Description: "解散班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "disband_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "解散班级师生群并删除关联关系",
UseWhen: []string{"需要解散指定班级的师生群并删除班级与群的关联关系时"},
AvoidWhen: []string{"查询师生群信息用 student-group info"},
Examples: []string{
"dws edu-group student-group disband --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// class-group 子命令组 — 班级群(家校群)会话管理
// ════════════════════════════════════════════════════════════
classGroupCmd := &cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE}
classGroupConversationIDCmd := &cobra.Command{
Use: "conversation-id",
Short: "获取班级群会话ID",
Long: `获取指定班级的班级群会话ID,可用于后续发送群消息等操作。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation-id --dept-id 12345
dws edu-group class-group conversation-id --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation_id", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationIDCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation_id",
CanonicalPath: "edu-group.get_class_conversation_id",
CLIPath: "edu-group class-group conversation-id",
PrimaryCLIPath: "edu-group class-group conversation-id",
},
Description: "获取班级群会话ID",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation_id"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群会话ID",
UseWhen: []string{"需要获取指定班级的班级群会话ID以便后续发送群消息时"},
AvoidWhen: []string{"需要完整群信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group conversation-id --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "获取班级群完整会话信息",
Long: `获取指定班级的班级群完整会话信息。
返回班级群的会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation --dept-id 12345
dws edu-group class-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation",
CanonicalPath: "edu-group.get_class_conversation",
CLIPath: "edu-group class-group conversation",
PrimaryCLIPath: "edu-group class-group conversation",
},
Description: "获取班级群完整会话信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群完整会话信息",
UseWhen: []string{"需要查询指定班级的班级群会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需会话ID用 class-group conversation-id"},
Examples: []string{
"dws edu-group class-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查班级群是否存在",
Long: `检查指定班级是否已创建班级群。返回班级群是否存在(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group exists --dept-id 12345
dws edu-group class-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_conversation_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_conversation_exists",
CanonicalPath: "edu-group.check_class_conversation_exists",
CLIPath: "edu-group class-group exists",
PrimaryCLIPath: "edu-group class-group exists",
},
Description: "检查班级群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_conversation_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查班级群是否存在",
UseWhen: []string{"需要判断指定班级是否已创建班级群时"},
AvoidWhen: []string{"查询班级群会话信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupListByConversationIDsCmd := &cobra.Command{
Use: "list-by-cids",
Short: "根据会话ID列表批量查询群信息",
Long: `根据群会话ID列表批量查询群的详细信息。
返回群会话详情列表,每项包含会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3
dws edu-group class-group list-by-cids --conversation-ids cid1,cid2 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("conversation-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--conversation-ids 为必填参数")
}
conversationIDs := eduGroupParseCSV(raw)
if len(conversationIDs) == 0 {
return fmt.Errorf("--conversation-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "list_groups_by_conversation_ids", map[string]any{
"input": map[string]any{"conversationIds": conversationIDs},
})
},
}
DeclareLeafMetadata(classGroupListByConversationIDsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "list_groups_by_conversation_ids",
CanonicalPath: "edu-group.list_groups_by_conversation_ids",
CLIPath: "edu-group class-group list-by-cids",
PrimaryCLIPath: "edu-group class-group list-by-cids",
},
Description: "根据会话ID列表批量查询群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "list_groups_by_conversation_ids"},
},
Selection: contract.SelectionSpec{
AgentSummary: "根据会话ID列表批量查询群信息",
UseWhen: []string{"需要根据一组群会话ID批量查询群的详细信息时"},
AvoidWhen: []string{"按班级ID批量查询用 batch get-class-groups"},
Examples: []string{
"dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-ids", Property: "input.conversationIds", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// batch 子命令组 — 批量操作
// ════════════════════════════════════════════════════════════
batchCmd := &cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE}
batchCheckClassGroupCmd := &cobra.Command{
Use: "check-student-group",
Short: "批量检查班级是否已创建师生群",
Long: `批量检查多个班级是否已创建师生群。返回班级ID与群会话ID(cid)的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch check-student-group --class-ids 12345,67890
dws edu-group batch check-student-group --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs, err := eduGroupParseIntCSV(raw)
if err != nil {
return fmt.Errorf("--class-ids 须为逗号分隔的整数列表: %w", err)
}
return callMCPToolOnServer("edu-group", "batch_check_class_group", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchCheckClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_check_class_group",
CanonicalPath: "edu-group.batch_check_class_group",
CLIPath: "edu-group batch check-student-group",
PrimaryCLIPath: "edu-group batch check-student-group",
},
Description: "批量检查班级是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_check_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量检查班级是否已创建师生群",
UseWhen: []string{"需要批量检查多个班级是否已创建师生群时"},
AvoidWhen: []string{"单个班级检查用 student-group exists"},
Examples: []string{
"dws edu-group batch check-student-group --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchGetClassConversationsCmd := &cobra.Command{
Use: "get-class-groups",
Short: "批量获取班级群信息",
Long: `批量获取多个班级的班级群会话信息。返回班级ID与群会话信息的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch get-class-groups --class-ids 12345,67890
dws edu-group batch get-class-groups --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs := eduGroupParseCSV(raw)
if len(classIDs) == 0 {
return fmt.Errorf("--class-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "batch_get_class_conversations", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchGetClassConversationsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_get_class_conversations",
CanonicalPath: "edu-group.batch_get_class_conversations",
CLIPath: "edu-group batch get-class-groups",
PrimaryCLIPath: "edu-group batch get-class-groups",
},
Description: "批量获取班级群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_get_class_conversations"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量获取班级群会话信息",
UseWhen: []string{"需要批量获取多个班级的班级群会话信息时"},
AvoidWhen: []string{"按会话ID批量查询用 class-group list-by-cids"},
Examples: []string{
"dws edu-group batch get-class-groups --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchCreateClassGroupCmd := &cobra.Command{
Use: "create-student-groups",
Short: "批量创建师生群",
Long: `为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群。
小学和幼儿园学段的班级不会创建师生群。仅限管理员角色调用。`,
Example: ` dws edu-group batch create-student-groups
dws edu-group batch create-student-groups -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("edu-group", "batch_create_class_group", map[string]any{
"input": map[string]any{},
})
},
}
DeclareLeafMetadata(batchCreateClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_create_class_group",
CanonicalPath: "edu-group.batch_create_class_group",
CLIPath: "edu-group batch create-student-groups",
PrimaryCLIPath: "edu-group batch create-student-groups",
},
Description: "批量创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为组织下符合条件的班级批量创建师生群",
UseWhen: []string{"需要为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群时"},
AvoidWhen: []string{"单个班级创建用 student-group create"},
Examples: []string{
"dws edu-group batch create-student-groups --format json",
},
},
Parameters: []contract.ParamDecl{},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// student-group flags
studentGroupInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupMembersCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupIsInCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupCreateCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupDisbandCmd.Flags().String("dept-id", "", "班级 ID(必填)")
// class-group flags
classGroupConversationIDCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupListByConversationIDsCmd.Flags().String("conversation-ids", "", "群会话 ID 列表,逗号分隔(必填)")
// batch flags
batchCheckClassGroupCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
batchGetClassConversationsCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
studentGroupCmd.AddCommand(
studentGroupInfoCmd, studentGroupExistsCmd, studentGroupMembersCmd,
studentGroupIsInCmd, studentGroupConversationCmd,
studentGroupCreateCmd, studentGroupDisbandCmd,
)
classGroupCmd.AddCommand(
classGroupConversationIDCmd, classGroupConversationCmd,
classGroupExistsCmd, classGroupListByConversationIDsCmd,
)
batchCmd.AddCommand(batchCheckClassGroupCmd, batchGetClassConversationsCmd, batchCreateClassGroupCmd)
root.AddCommand(studentGroupCmd, classGroupCmd, batchCmd)
return root
}
// eduGroupRequiredIntFlag extracts a required integer flag, returning an error
// if the flag is empty or not a valid integer.
func eduGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduGroupParseCSV splits a comma-separated string into trimmed non-empty values.
func eduGroupParseCSV(raw string) []string {
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
result = append(result, v)
}
}
return result
}
// eduGroupParseIntCSV splits a comma-separated string into int64 values.
func eduGroupParseIntCSV(raw string) ([]int64, error) {
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", v)
}
result = append(result, n)
}
return result, nil
}
+366
View File
@@ -0,0 +1,366 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
// withEduGroupCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"student-group", "info", "--dept-id", "123"},
{"student-group", "exists", "--dept-id", "123"},
{"student-group", "members", "--dept-id", "123"},
{"student-group", "is-in", "--dept-id", "123"},
{"student-group", "conversation", "--dept-id", "123"},
{"student-group", "create", "--dept-id", "123"},
{"student-group", "disband", "--dept-id", "123"},
{"class-group", "conversation-id", "--dept-id", "123"},
{"class-group", "conversation", "--dept-id", "123"},
{"class-group", "exists", "--dept-id", "123"},
{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2"},
{"batch", "check-student-group", "--class-ids", "1,2"},
{"batch", "get-class-groups", "--class-ids", "1,2"},
{"batch", "create-student-groups"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupMissingRequiredFlags(t *testing.T) {
// Each dept-id command must reject an absent --dept-id, covering its own
// error branch as well as the shared eduGroupRequiredIntFlag empty case.
deptIDCommands := [][]string{
{"student-group", "info"},
{"student-group", "exists"},
{"student-group", "members"},
{"student-group", "is-in"},
{"student-group", "conversation"},
{"student-group", "create"},
{"student-group", "disband"},
{"class-group", "conversation-id"},
{"class-group", "conversation"},
{"class-group", "exists"},
}
for _, args := range deptIDCommands {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "dept-id") {
t.Fatalf("Execute(%v) error = %v, want dept-id required", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupFlagValidation(t *testing.T) {
t.Run("non-integer dept-id", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "abc"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("non-integer dept-id error = %v", err)
}
})
t.Run("list-by-cids missing conversation-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("missing conversation-ids error = %v", err)
}
})
t.Run("list-by-cids only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("empty conversation-ids error = %v", err)
}
})
t.Run("batch check missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch check invalid integer class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "x"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("invalid integer class-ids error = %v", err)
}
})
t.Run("batch get missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch get only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups", "--class-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("empty class-ids error = %v", err)
}
})
}
func TestCrossPlatformCoverageEduGroupParseHelpers(t *testing.T) {
if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduGroupParseCSV = %#v", got)
}
ids, err := eduGroupParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduGroupParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduGroupParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduGroupParseIntCSV invalid = nil error")
}
}
// withEduGroupDispatchCaller installs a non-dry-run capture caller so commands
// exercise the full dispatch path through deps.Caller.CallTool.
func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) {
t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "123"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "get_class_group_info" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_class_group_info")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(123) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(123)", input["deptId"])
}
})
t.Run("student-group create dispatches create_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "create", "--dept-id", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "create_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(456) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(456)", input["deptId"])
}
})
t.Run("student-group disband dispatches disband_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.PersistentFlags().Bool("yes", false, "")
cmd.PersistentFlags().Bool("dry-run", false, "")
cmd.SetArgs([]string{"student-group", "disband", "--dept-id", "789", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "disband_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "disband_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(789) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(789)", input["deptId"])
}
})
t.Run("class-group list-by-cids dispatches list_groups_by_conversation_ids", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "list_groups_by_conversation_ids" {
t.Fatalf("tool = %q, want %q", caller.tool, "list_groups_by_conversation_ids")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
cids, ok := input["conversationIds"].([]string)
if !ok || len(cids) != 3 || cids[0] != "cid1" || cids[1] != "cid2" || cids[2] != "cid3" {
t.Fatalf("input[\"conversationIds\"] = %#v, want [cid1 cid2 cid3]", input["conversationIds"])
}
})
t.Run("batch check-student-group dispatches batch_check_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "100,200"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "batch_check_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "batch_check_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
classIDs, ok := input["classIds"].([]int64)
if !ok || len(classIDs) != 2 || classIDs[0] != 100 || classIDs[1] != 200 {
t.Fatalf("input[\"classIds\"] = %#v, want [100 200]", input["classIds"])
}
})
}
// newEduGroupConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduGroupConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduGroupCommand())
return root
}
// TestCrossPlatformCoverageEduGroupDestructiveConfirmGate 对 edu-group 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduGroupDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"student-group disband",
[]string{"edu-group", "student-group", "disband", "--dept-id", "12345"},
"disband_class_group",
map[string]any{"deptId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-group" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-group")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import "github.com/spf13/cobra"
// 家校/高校系列(家校通讯录、家校群、家校应用、家庭群、高校通讯录)是开源库
// 显式维护的公开命令,不依赖生成式产品注册表(register_products.go)。
// 其 MCP server 端点由运行时宿主按 serverID 解析。
func init() {
eduCommands := []struct {
name string
buildFn func() *cobra.Command
}{
{"edu-contact", newEduContactCommand},
{"edu-group", newEduGroupCommand},
{"edu-app", newEduAppCommand},
{"edu-familygroup", newEduFamilyGroupCommand},
{"college-contact", newCollegeContactCommand},
}
for _, c := range eduCommands {
c := c
RegisterPublic(func() Handler {
return wukongHandler{name: c.name, buildFn: c.buildFn}
})
}
}
@@ -0,0 +1,280 @@
package helpers
import (
"fmt"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
)
// TestRunAllEduCommands 逐条执行 5 个教育产品的全部 156 条叶子命令(dry-run 模式),
// 并打印每条命令对应的 MCP 工具名和参数。相当于在终端逐一执行 dws <cmd> --dry-run。
func TestRunAllEduCommands(t *testing.T) {
type cmdCase struct {
product string
args []string
}
allCmds := []cmdCase{
// ═══════════════════════════════════════════════════════════
// edu-contact: 29 commands
// ═══════════════════════════════════════════════════════════
// school (6)
{"edu-contact", []string{"school", "roles"}},
{"edu-contact", []string{"school", "structure"}},
{"edu-contact", []string{"school", "periods"}},
{"edu-contact", []string{"school", "type"}},
{"edu-contact", []string{"school", "stats", "--statistics-type", "1"}},
{"edu-contact", []string{"school", "class-list"}},
// class (19)
{"edu-contact", []string{"class", "detail", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "students", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "teachers", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "same-name", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "user-role", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "search-by-name", "--query-type", "student", "--name", "张三"}},
{"edu-contact", []string{"class", "headmaster", "--class-name", "一年级1班"}},
{"edu-contact", []string{"class", "search-by-teacher", "--name", "张老师"}},
{"edu-contact", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"}},
{"edu-contact", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--student-mobile", "13800138000"}},
{"edu-contact", []string{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "小明"}},
{"edu-contact", []string{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"}},
{"edu-contact", []string{"class", "update-info", "--class-id", "123", "--nick", "火箭班"}},
{"edu-contact", []string{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S001"}},
{"edu-contact", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"}},
{"edu-contact", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"}},
{"edu-contact", []string{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"}},
{"edu-contact", []string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"}},
{"edu-contact", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"}},
// family (2)
{"edu-contact", []string{"family", "children"}},
{"edu-contact", []string{"family", "parents"}},
// teacher (2)
{"edu-contact", []string{"teacher", "classes"}},
{"edu-contact", []string{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"MATH","courseName":"数学"}]`}},
// ═══════════════════════════════════════════════════════════
// edu-group: 14 commands
// ═══════════════════════════════════════════════════════════
// student-group (7)
{"edu-group", []string{"student-group", "info", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "exists", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "members", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "is-in", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "conversation", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "create", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "disband", "--dept-id", "12345"}},
// class-group (4)
{"edu-group", []string{"class-group", "conversation-id", "--dept-id", "12345"}},
{"edu-group", []string{"class-group", "conversation", "--dept-id", "12345"}},
{"edu-group", []string{"class-group", "exists", "--dept-id", "12345"}},
{"edu-group", []string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"}},
// batch (3)
{"edu-group", []string{"batch", "check-student-group", "--class-ids", "12345,67890"}},
{"edu-group", []string{"batch", "get-class-groups", "--class-ids", "12345,67890"}},
{"edu-group", []string{"batch", "create-student-groups"}},
// ═══════════════════════════════════════════════════════════
// edu-app: 42 commands
// ═══════════════════════════════════════════════════════════
// message (1)
{"edu-app", []string{"message", "summary-list", "--class-id", "1", "--cid", "c1", "--target-role", "guardian", "--status", "0"}},
// task (3)
{"edu-app", []string{"task", "publish-list"}},
{"edu-app", []string{"task", "all-list", "--biz-id", "1"}},
{"edu-app", []string{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`}},
// report (5)
{"edu-app", []string{"report", "get", "--ids", "1001,1002"}},
{"edu-app", []string{"report", "by-teacher"}},
{"edu-app", []string{"report", "by-class", "--report-id", "1001", "--class-id", "12345"}},
{"edu-app", []string{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1"}},
{"edu-app", []string{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"}},
// notice (7)
{"edu-app", []string{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1"}},
{"edu-app", []string{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假"}},
{"edu-app", []string{"notice", "delete", "--notice-id", "12345"}},
{"edu-app", []string{"notice", "list-by-teacher"}},
{"edu-app", []string{"notice", "get", "--notice-id", "12345"}},
{"edu-app", []string{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c1"}},
{"edu-app", []string{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
// circle (1)
{"edu-app", []string{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"}},
// card (5)
{"edu-app", []string{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题"}},
{"edu-app", []string{"card", "end", "--card-id", "1"}},
{"edu-app", []string{"card", "list", "--status", "UNFINISH"}},
{"edu-app", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1"}},
{"edu-app", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1"}},
// diploma (9)
{"edu-app", []string{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "三好学生", "--user-name", "张三"}},
{"edu-app", []string{"diploma", "read", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "list-by-teacher"}},
{"edu-app", []string{"diploma", "get", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "statistics", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "detail", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
{"edu-app", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c1"}},
{"edu-app", []string{"diploma", "delete", "--diploma-id", "1"}},
// homework (11)
{"edu-app", []string{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习册第3页"}},
{"edu-app", []string{"homework", "delete", "--homework-id", "1"}},
{"edu-app", []string{"homework", "submit", "--hw-content-detail-id", "1"}},
{"edu-app", []string{"homework", "get", "--homework-id", "1"}},
{"edu-app", []string{"homework", "class-by-homework", "--homework-id", "1"}},
{"edu-app", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c1", "--user-name", "张老师"}},
{"edu-app", []string{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c1"}},
{"edu-app", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c1", "--user-name", "张三"}},
{"edu-app", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c1"}},
{"edu-app", []string{"homework", "list-by-teacher"}},
{"edu-app", []string{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1"}},
// ═══════════════════════════════════════════════════════════
// edu-familygroup: 6 commands
// ═══════════════════════════════════════════════════════════
// group (2)
{"edu-familygroup", []string{"group", "check-exists", "--uid", "12345", "--group-name", "小明一家"}},
{"edu-familygroup", []string{"group", "list-children", "--uid", "12345"}},
// manage (4)
{"edu-familygroup", []string{"manage", "create", "--uid", "12345", "--children", `[{"name":"小明","students":[{"corpId":"corp1","staffId":"staff1"}]}]`}},
{"edu-familygroup", []string{"manage", "invite-parent", "--org-id", "12345", "--uid", "67890", "--mobile", "13800138000"}},
{"edu-familygroup", []string{"manage", "add-child", "--org-id", "12345", "--uid", "67890", "--name", "小红", "--mobile", "13900139000"}},
{"edu-familygroup", []string{"manage", "toggle-app", "--org-id", "12345", "--uid", "67890", "--child-staff-id", "staff1", "--app-type", "XIAOTIANDI", "--open", "true"}},
// ═══════════════════════════════════════════════════════════
// college-contact: 65 commands
// ═══════════════════════════════════════════════════════════
// dept (9)
{"college-contact", []string{"dept", "get-standard-structure"}},
{"college-contact", []string{"dept", "get-detail", "--dept-id", "12345"}},
{"college-contact", []string{"dept", "get-chain", "--dept-id", "12345"}},
{"college-contact", []string{"dept", "search", "--dept-id", "12345", "--keyword", "计算机"}},
{"college-contact", []string{"dept", "create", "--super-id", "1", "--stru-dept-id", "2", "--name", "计算机学院", "--dept-type", "COLLEGE", "--create-dept-group", "true"}},
{"college-contact", []string{"dept", "update", "--dept-id", "12345", "--dept-type", "COLLEGE"}},
{"college-contact", []string{"dept", "delete", "--dept-id", "12345"}},
{"college-contact", []string{"dept", "batch-update-type", "--dept-ids", "1,2,3", "--target-dept-type", "COLLEGE"}},
{"college-contact", []string{"dept", "overview"}},
// employee (11)
{"college-contact", []string{"employee", "get-detail", "--staff-id", "staff001"}},
{"college-contact", []string{"employee", "add", "--emp-type", "TEACHER", "--main-dept-id", "12345", "--exclusive-account", "false"}},
{"college-contact", []string{"employee", "remove", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"employee", "change-type", "--staff-id", "staff001", "--emp-type", "STUDENT"}},
{"college-contact", []string{"employee", "change-dept", "--staff-id", "staff001", "--target-dept-id", "67890"}},
{"college-contact", []string{"employee", "send-active-sms", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "list-employees", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "list-unaccepted", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "list-unactive", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "upgrade-status"}},
{"college-contact", []string{"employee", "start-upgrade"}},
// alumni (20)
{"college-contact", []string{"alumni", "get-dept-tree", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "get-info", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "list", "--alumni-dept-id", "12345", "--order-field", "NAME", "--ordering", "ASC"}},
{"college-contact", []string{"alumni", "query", "--staff-id", "staff001"}},
{"college-contact", []string{"alumni", "search", "--keyword", "张三"}},
{"college-contact", []string{"alumni", "list-unaccepted", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "get-group", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "create-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届"}},
{"college-contact", []string{"alumni", "update-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届计算机"}},
{"college-contact", []string{"alumni", "delete-dept", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "update-managers", "--alumni-dept-id", "12345", "--admin-user-ids", "u1,u2"}},
{"college-contact", []string{"alumni", "add-alumnus", "--dept-ids", "1,2", "--name", "张三", "--mobile", "13800138000"}},
{"college-contact", []string{"alumni", "update-alumnus", "--dept-ids", "1,2", "--staff-id", "s1", "--name", "张三"}},
{"college-contact", []string{"alumni", "remove-alumnus", "--staff-id", "s1", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "cancel-invite", "--alumni-dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"alumni", "create-group", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "disband-group", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "get-alumni-org-from-graduate"}},
{"college-contact", []string{"alumni", "create-alumni-org", "--org-name", "计算机校友会"}},
{"college-contact", []string{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"}},
// graduate (16)
{"college-contact", []string{"graduate", "query-graduate-years"}},
{"college-contact", []string{"graduate", "query-graduate-depts", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "query-graduate-sub-depts", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "query-page-graduate-users", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "get-task-result", "--request-no", "req001"}},
{"college-contact", []string{"graduate", "get-alumni-org"}},
{"college-contact", []string{"graduate", "query-restore-sub-depts", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "query-dept-deleted-emps", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "search-graduate", "--keyword", "张三"}},
{"college-contact", []string{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}},
{"college-contact", []string{"graduate", "all-graduate", "--graduate-year", "2026"}},
{"college-contact", []string{"graduate", "batch-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"graduate", "delete-and-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"graduate", "batch-delete-pending", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"graduate", "batch-update-pending", "--dept-id", "12345", "--staff-ids", "s1,s2", "--graduate-year", "2026"}},
{"college-contact", []string{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"}},
// group (9)
{"college-contact", []string{"group", "query-group-rule"}},
{"college-contact", []string{"group", "get-group-rule-schedule"}},
{"college-contact", []string{"group", "query-preview-data"}},
{"college-contact", []string{"group", "create-group-rule", "--name", "自动分组", "--tag-code", "TAG1", "--dept-type", "COLLEGE"}},
{"college-contact", []string{"group", "delete-group-rule", "--rule-id", "1"}},
{"college-contact", []string{"group", "enable-group-rule", "--rule-id", "1"}},
{"college-contact", []string{"group", "disable-group-rule", "--rule-id", "1"}},
{"college-contact", []string{"group", "set-group-rule-schedule"}},
{"college-contact", []string{"group", "execute-group-rule"}},
}
// 按产品分组的命令构建器
builders := map[string]func() *cobra.Command{
"edu-contact": newEduContactCommand,
"edu-group": newEduGroupCommand,
"edu-app": newEduAppCommand,
"edu-familygroup": newEduFamilyGroupCommand,
"college-contact": newCollegeContactCommand,
}
// 安装 dry-run caller
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
passed := 0
failed := 0
currentProduct := ""
for i, c := range allCmds {
if c.product != currentProduct {
currentProduct = c.product
fmt.Printf("\n══════════════════════════════════════════════════════\n")
fmt.Printf(" %s\n", strings.ToUpper(currentProduct))
fmt.Printf("══════════════════════════════════════════════════════\n")
}
buildFn, ok := builders[c.product]
if !ok {
t.Fatalf("unknown product: %s", c.product)
}
root := buildFn()
root.SetArgs(c.args)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
// Reset caller capture
caller.productID = ""
caller.tool = ""
caller.args = nil
err := root.Execute()
cmdPath := fmt.Sprintf("dws %s %s", c.product, strings.Join(c.args, " "))
if err != nil {
failed++
fmt.Printf(" [%3d] ✗ FAIL: %s\n", i+1, cmdPath)
fmt.Printf(" Error: %v\n", err)
t.Errorf("command %d failed: %s → %v", i+1, cmdPath, err)
} else {
passed++
fmt.Printf(" [%3d] ✓ %s\n", i+1, cmdPath)
if caller.tool != "" {
fmt.Printf(" → MCP: %s.%s\n", caller.productID, caller.tool)
}
}
}
fmt.Printf("\n══════════════════════════════════════════════════════\n")
fmt.Printf(" SUMMARY: %d passed, %d failed, %d total\n", passed, failed, passed+failed)
fmt.Printf("══════════════════════════════════════════════════════\n")
}
+53 -18
View File
@@ -15,18 +15,21 @@ func newFloatImageCmds() []*cobra.Command {
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
使用流程:
1. 先通过 media-upload 上传本地图片获取 resourceUrl
2. 再通过 create-float-image 将图片以浮动方式放置到工作表上
直接传 --file 可在一个命令中上传本地图片并创建浮动图片。
已经通过 media-upload 获得 resourceUrl 时,也可以改传 --src;--file 与 --src 二选一。
--range 指定浮动图片锚定的单元格位置,使用 A1 表示法(如 A1、B3)。
--width / --height 为必填,单位像素,必须为正整数。
--offset-x / --offset-y 可选,表示相对锚点单元格左上角的偏移量(像素),默认 0。`,
Example: ` # 先上传图片获取 resourceUrl
Example: ` # 直接从本地文件创建
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
--file ./chart.png --range A1 --width 400 --height 300
# 高级用法:先上传图片获取 resourceUrl
dws sheet media-upload --node NODE_ID --file ./chart.png
# 输出: resourceUrl: /core/api/resources/img/xxxx...
# 再创建浮动图片(--src 传入 media-upload 返回的 resourceUrl)
# 再通过 --src 创建浮动图片
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
--src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300
@@ -34,7 +37,11 @@ func newFloatImageCmds() []*cobra.Command {
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
--src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "src", "range"); err != nil {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
filePath, src, err := validateFloatImageCreateInput(cmd)
if err != nil {
return err
}
width, err := cmd.Flags().GetInt("width")
@@ -54,7 +61,6 @@ func newFloatImageCmds() []*cobra.Command {
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"src": mustGetFlag(cmd, "src"),
"range": mustGetFlag(cmd, "range"),
"width": width,
"height": height,
@@ -73,7 +79,11 @@ func newFloatImageCmds() []*cobra.Command {
}
toolArgs["offsetY"] = oy
}
return callMCPTool("create_float_image", toolArgs)
if filePath != "" {
return runFloatImageFileMode(cmd, "create_float_image", filePath, toolArgs)
}
toolArgs["src"] = src
return callMCPToolContext(cmd.Context(), "create_float_image", toolArgs)
},
}
DeclareLeafMetadata(createFloatImageCmd, LeafSpec{
@@ -89,26 +99,30 @@ func newFloatImageCmds() []*cobra.Command {
CLIPath: "sheet create-float-image",
PrimaryCLIPath: "sheet create-float-image",
},
Description: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
Description: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_float_image"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
AgentSummary: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
UseWhen: []string{"需要在单元格上方悬浮图片、不占用单元格内容时"},
AvoidWhen: []string{"单元格内嵌图片用 write-image;更新/删除浮动图用 update/delete-float-image"},
Examples: []string{"dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --src \"/core/api/resources/img/...\" --range A1 --width 400 --height 300"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "file", Required: boolPtr(false), RequiredWhen: "exactly one of --file or --src must be provided"},
{Name: "src", Property: "src", Required: boolPtr(false)},
},
},
})
createFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
createFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
createFloatImageCmd.Flags().String("src", "", "图片资源路径,通过 media-upload 获取的 resourceUrl (必填)")
createFloatImageCmd.Flags().String("file", "", "本地图片文件路径,与 --src 二选一")
createFloatImageCmd.Flags().String("src", "", "通过 media-upload 获取的 resourceUrl,与 --file 二选一")
createFloatImageCmd.Flags().String("range", "", "锚点单元格,A1 表示法,如 A1、B3 (必填)")
createFloatImageCmd.Flags().Int("width", 0, "图片宽度,像素 (必填)")
createFloatImageCmd.Flags().Int("height", 0, "图片高度,像素 (必填)")
@@ -217,8 +231,8 @@ floatImageId 可通过 list-float-images 获取。`,
Short: "更新浮动图片属性",
Long: `更新钉钉表格指定工作表中浮动图片的属性。
可更新的属性包括:图片资源路径(src)、锚点位置、尺寸、偏移量。
至少需要传入一个更新字段(--src / --range / --width / --height / --offset-x / --offset-y)。
可直接传 --file 上传本地图片并替换浮动图片,也可传已上传图片的 --src。
至少需要传入一个更新字段(--file / --src / --range / --width / --height / --offset-x / --offset-y),且 --file 与 --src 不能同时使用。
floatImageId 可通过 list-float-images 获取。`,
Example: ` # 移动浮动图片到新位置
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --range C5
@@ -226,10 +240,15 @@ floatImageId 可通过 list-float-images 获取。`,
# 调整尺寸
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --width 600 --height 400
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
# 直接用本地文件替换图片
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
--file ./replacement.png
# 高级用法:用 media-upload 返回的 resourceUrl 替换图片
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
--src "/core/api/resources/img/xxxx..."`,
RunE: func(cmd *cobra.Command, args []string) error {
fileChanged := cmd.Flags().Changed("file")
srcChanged := cmd.Flags().Changed("src")
rangeChanged := cmd.Flags().Changed("range")
widthChanged := cmd.Flags().Changed("width")
@@ -237,8 +256,17 @@ floatImageId 可通过 list-float-images 获取。`,
oxChanged := cmd.Flags().Changed("offset-x")
oyChanged := cmd.Flags().Changed("offset-y")
if !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
return fmt.Errorf("--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个")
if !fileChanged && !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
return fmt.Errorf("%s", floatImageUpdateFieldsError)
}
filePath, src, err := validateFloatImageUpdateInput(cmd)
if err != nil {
return err
}
if fileChanged {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "float-image-id"); err != nil {
return err
}
}
toolArgs := map[string]any{
@@ -247,7 +275,7 @@ floatImageId 可通过 list-float-images 获取。`,
"floatImageId": mustGetFlag(cmd, "float-image-id"),
}
if srcChanged {
toolArgs["src"], _ = cmd.Flags().GetString("src")
toolArgs["src"] = src
}
if rangeChanged {
toolArgs["range"], _ = cmd.Flags().GetString("range")
@@ -280,7 +308,10 @@ floatImageId 可通过 list-float-images 获取。`,
}
toolArgs["offsetY"] = oy
}
return callMCPTool("update_float_image", toolArgs)
if fileChanged {
return runFloatImageFileMode(cmd, "update_float_image", filePath, toolArgs)
}
return callMCPToolContext(cmd.Context(), "update_float_image", toolArgs)
},
}
DeclareLeafMetadata(updateFloatImageCmd, LeafSpec{
@@ -297,6 +328,7 @@ floatImageId 可通过 list-float-images 获取。`,
PrimaryCLIPath: "sheet update-float-image",
},
Description: "更新浮动图片锚点、尺寸、偏移或资源路径。",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
@@ -310,12 +342,15 @@ floatImageId 可通过 list-float-images 获取。`,
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "file", Required: boolPtr(false)},
{Name: "src", Property: "src", Required: boolPtr(false)},
},
},
})
updateFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
updateFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
updateFloatImageCmd.Flags().String("float-image-id", "", "浮动图片 ID (必填)")
updateFloatImageCmd.Flags().String("file", "", "用于替换浮动图片的本地图片路径,与 --src 不能同时使用")
updateFloatImageCmd.Flags().String("src", "", "新的图片资源路径,通过 media-upload 获取的 resourceUrl")
updateFloatImageCmd.Flags().String("range", "", "新的锚点单元格,A1 表示法")
updateFloatImageCmd.Flags().Int("width", 0, "新的图片宽度,像素")
+333
View File
@@ -0,0 +1,333 @@
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
const (
floatImageCreateInputError = "--file 与 --src 必须且只能提供一个"
floatImageUpdateInputError = "--file 与 --src 不能同时提供"
floatImageUpdateFieldsError = "--file、--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个"
floatImagePlannedResource = "<resourceUrl-after-upload>"
floatImageUploadTimeout = 5 * time.Minute
)
type floatImageLocalFile struct {
file *os.File
path string
name string
mimeType string
size int64
}
type floatImageUploadInfo struct {
uploadURL string
resourceID string
resourceURL string
}
func validateFloatImageCreateInput(cmd *cobra.Command) (string, string, error) {
filePath, _ := cmd.Flags().GetString("file")
src, _ := cmd.Flags().GetString("src")
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
return "", "", fmt.Errorf("--file 不能为空")
}
hasFile := strings.TrimSpace(filePath) != ""
hasSrc := strings.TrimSpace(src) != ""
if hasFile == hasSrc {
return "", "", fmt.Errorf("%s", floatImageCreateInputError)
}
return filePath, src, nil
}
func validateFloatImageUpdateInput(cmd *cobra.Command) (string, string, error) {
filePath, _ := cmd.Flags().GetString("file")
src, _ := cmd.Flags().GetString("src")
if cmd.Flags().Changed("file") && cmd.Flags().Changed("src") {
return "", "", fmt.Errorf("%s", floatImageUpdateInputError)
}
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
return "", "", fmt.Errorf("--file 不能为空")
}
return filePath, src, nil
}
func runFloatImageFileMode(cmd *cobra.Command, toolName, filePath string, toolArgs map[string]any) error {
local, err := openFloatImageLocalFile(filePath)
if err != nil {
return err
}
defer local.file.Close()
if deps.Caller.DryRun() {
return printFloatImageFileDryRun(toolName, toolArgs, local)
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
jsonMode := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json")
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取浮动图片上传凭证 (%s, %d bytes)...", local.name, local.size))
}
uploadInfo, err := resolveFloatImageUploadInfo(ctx, toolArgs["nodeId"], local)
if err != nil {
return err
}
if !jsonMode {
deps.Out.PrintKeyValue("resourceId", uploadInfo.resourceID)
deps.Out.PrintKeyValue("resourceUrl", uploadInfo.resourceURL)
deps.Out.PrintInfo("[2/3] 上传本地图片...")
}
client := newFloatImageUploadClient()
if err := putFloatImageFileWithClient(ctx, client, uploadInfo.uploadURL, local); err != nil {
return err
}
if !jsonMode {
deps.Out.PrintInfo("[3/3] 提交浮动图片变更...")
}
toolArgs["src"] = uploadInfo.resourceURL
if err := callMCPToolContext(ctx, toolName, toolArgs); err != nil {
return fmt.Errorf("图片已上传(resourceId=%s,resourceUrl=%s),但浮动图片操作失败;已有重试可能造成重复创建或更新,请先用 list/get/UI 核验后再重试: %w", uploadInfo.resourceID, uploadInfo.resourceURL, err)
}
return nil
}
func openFloatImageLocalFile(path string) (*floatImageLocalFile, error) {
return openFloatImageLocalFileWith(path, os.Stat, os.Open)
}
func openFloatImageLocalFileWith(path string, stat func(string) (os.FileInfo, error), open func(string) (*os.File, error)) (*floatImageLocalFile, error) {
info, err := stat(path)
if err != nil {
return nil, fmt.Errorf("无法读取 --file %q: %w", path, err)
}
if err := validateFloatImageFileInfo(info); err != nil {
return nil, err
}
file, err := open(path)
if err != nil {
return nil, fmt.Errorf("无法打开 --file %q: %w", path, err)
}
openedInfo, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("无法读取已打开的 --file %q: %w", path, err)
}
if err := validateFloatImageFileInfo(openedInfo); err != nil {
_ = file.Close()
return nil, err
}
return &floatImageLocalFile{
file: file,
path: path,
name: filepath.Base(path),
mimeType: inferMimeType(filepath.Base(path)),
size: openedInfo.Size(),
}, nil
}
func validateFloatImageFileInfo(info os.FileInfo) error {
if !info.Mode().IsRegular() {
return fmt.Errorf("--file 必须是普通文件")
}
if info.Size() <= 0 {
return fmt.Errorf("--file 不能为空文件")
}
return nil
}
func printFloatImageFileDryRun(toolName string, toolArgs map[string]any, local *floatImageLocalFile) error {
arguments := make(map[string]any, len(toolArgs)+1)
for key, value := range toolArgs {
arguments[key] = value
}
arguments["src"] = floatImagePlannedResource
stages := []string{"validate_local_file", "get_upload_credentials", "upload_file", toolName}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": toolName,
"arguments": arguments,
"local_file": map[string]any{
"path": local.path, "name": local.name, "mime_type": local.mimeType, "size": local.size,
},
"stages": stages,
})
}
deps.Out.PrintKeyValue("操作", "上传本地图片并提交浮动图片变更")
deps.Out.PrintKeyValue("文件", local.path)
deps.Out.PrintKeyValue("名称", local.name)
deps.Out.PrintKeyValue("类型", local.mimeType)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", local.size))
deps.Out.PrintKeyValue("Tool", toolName)
deps.Out.PrintKeyValue("阶段", strings.Join(stages, " -> "))
return nil
}
func resolveFloatImageUploadInfo(ctx context.Context, nodeID any, local *floatImageLocalFile) (floatImageUploadInfo, error) {
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": local.name,
"fileSize": float64(local.size),
"mimeType": local.mimeType,
})
if err != nil {
return floatImageUploadInfo{}, WrapError(err)
}
return parseFloatImageUploadInfo(result)
}
func parseFloatImageUploadInfo(result *edition.ToolResult) (floatImageUploadInfo, error) {
if result == nil {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
text := ""
for _, content := range result.Content {
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
text = content.Text
break
}
}
if text == "" {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
type credential struct {
UploadURL string `json:"uploadUrl"`
ResourceID string `json:"resourceId"`
ResourceURL string `json:"resourceUrl"`
}
var envelope struct {
credential
Result *credential `json:"result"`
}
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
parsed := envelope.credential
if envelope.Result != nil {
parsed = *envelope.Result
}
parsed.UploadURL = strings.TrimSpace(parsed.UploadURL)
parsed.ResourceID = strings.TrimSpace(parsed.ResourceID)
parsed.ResourceURL = strings.TrimSpace(parsed.ResourceURL)
if parsed.UploadURL == "" || !validFloatImageResourceID(parsed.ResourceID) || !validFloatImageResourceURL(parsed.ResourceURL) {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
if err := validateFloatImageUploadURL(parsed.UploadURL); err != nil {
return floatImageUploadInfo{}, err
}
return floatImageUploadInfo{uploadURL: parsed.UploadURL, resourceID: parsed.ResourceID, resourceURL: parsed.ResourceURL}, nil
}
func invalidFloatImageCredentialError() error {
return &CLIError{
Code: CodeMCPToolError,
Message: "获取浮动图片上传凭证失败:响应格式无效",
Suggestion: "请重试;若持续失败,请检查 doc/get_doc_attachment_upload_info 服务",
Operation: "doc/get_doc_attachment_upload_info",
}
}
func validFloatImageResourceID(value string) bool {
return value != "" && len(value) <= 4096 && strings.IndexFunc(value, unicode.IsControl) < 0
}
func validFloatImageResourceURL(value string) bool {
if value == "" || len(value) > 8192 || strings.IndexFunc(value, unicode.IsControl) >= 0 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
return false
}
parsed, err := url.Parse(value)
return err == nil && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == ""
}
// Keep this policy aligned with internal/transport.Client.isEndpointTrusted.
// It stays Sheet-private in V1 so local-file float-image support does not
// refactor the shared transport boundary selected by unrelated products.
func validateFloatImageUploadURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return invalidFloatImageUploadURLError()
}
if strings.EqualFold(parsed.Scheme, "https") {
return nil
}
if !strings.EqualFold(parsed.Scheme, "http") || os.Getenv("DWS_ALLOW_HTTP_ENDPOINTS") != "1" || !isFloatImageLoopbackHost(parsed.Hostname()) {
return invalidFloatImageUploadURLError()
}
return nil
}
func isFloatImageLoopbackHost(host string) bool {
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
func invalidFloatImageUploadURLError() error {
return &CLIError{
Code: CodeInvalidParam,
Message: "浮动图片上传地址无效或不受信任",
Suggestion: "上传地址必须是 HTTPS;本地开发仅允许显式开启的 loopback HTTP",
Operation: "sheet/float-image-upload",
}
}
func newFloatImageUploadClient() *http.Client {
return &http.Client{
Timeout: floatImageUploadTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
// This intentionally does not reuse doc.defaultHTTPPutFile or localio.PutFile:
// both have incompatible retry/status/host behavior, and the Doc helper can
// copy a signed URL or response body into errors. Keep this fixed-message PUT
// until a shared secure-upload contract can replace all upload implementations.
func putFloatImageFileWithClient(ctx context.Context, client *http.Client, rawURL string, local *floatImageLocalFile) error {
if client == nil {
return fmt.Errorf("浮动图片文件上传失败:HTTP 客户端不可用")
}
if _, err := local.file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("浮动图片文件上传失败:无法读取本地文件")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, local.file)
if err != nil {
return fmt.Errorf("浮动图片文件上传失败:无法创建请求")
}
request.ContentLength = local.size
request.Header.Set("Content-Type", local.mimeType)
response, err := client.Do(request)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("浮动图片文件上传已取消: %w", ctxErr)
}
if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) {
return fmt.Errorf("浮动图片文件上传失败:已超过传输时限")
}
return fmt.Errorf("浮动图片文件上传失败:网络请求失败")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
if response.StatusCode != http.StatusOK {
return fmt.Errorf("浮动图片文件上传失败:HTTP %d", response.StatusCode)
}
return nil
}
@@ -0,0 +1,20 @@
//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly
package helpers
import (
"path/filepath"
"strings"
"syscall"
"testing"
)
func TestCrossPlatformCoverageFloatImageFIFORejectedBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "image.fifo")
if err := syscall.Mkfifo(path, 0o600); err != nil {
t.Fatalf("mkfifo: %v", err)
}
if _, err := openFloatImageLocalFile(path); err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("FIFO error = %v", err)
}
}
@@ -0,0 +1,695 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type floatImageToolCall struct {
product string
tool string
args map[string]any
}
type floatImageTestCaller struct {
dryRun bool
format string
calls []floatImageToolCall
call func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error)
}
func (c *floatImageTestCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
copyArgs := make(map[string]any, len(args))
for key, value := range args {
copyArgs[key] = value
}
index := len(c.calls)
c.calls = append(c.calls, floatImageToolCall{product: product, tool: tool, args: copyArgs})
if c.call != nil {
return c.call(ctx, product, tool, args, index)
}
return floatImageTextResult(`{"floatImage":{"id":"fi-1"}}`), nil
}
func (c *floatImageTestCaller) Format() string {
if c.format == "" {
return "json"
}
return c.format
}
func (c *floatImageTestCaller) DryRun() bool { return c.dryRun }
func (*floatImageTestCaller) Fields() string { return "" }
func (*floatImageTestCaller) JQ() string { return "" }
type floatImageRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn floatImageRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func floatImageTextResult(text string) *edition.ToolResult {
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
}
func floatImageCredentialResult(uploadURL string, nested bool) *edition.ToolResult {
credential := map[string]any{
"uploadUrl": uploadURL, "resourceId": "rid-1", "resourceUrl": "/core/api/resources/img/rid-1",
}
payload := any(credential)
if nested {
payload = map[string]any{"result": credential}
}
raw, _ := json.Marshal(payload)
return floatImageTextResult(string(raw))
}
func floatImageCommandForTest(t *testing.T, name string) *cobra.Command {
t.Helper()
for _, command := range newFloatImageCmds() {
if command.Name() == name {
return command
}
}
t.Fatalf("missing float-image command %s", name)
return nil
}
func setFloatImageFlags(t *testing.T, command *cobra.Command, values map[string]string) {
t.Helper()
for name, value := range values {
if err := command.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
}
func installFloatImageDeps(t *testing.T, caller *floatImageTestCaller, output io.Writer) {
t.Helper()
if output == nil {
output = io.Discard
}
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: NewFormatterWithWriters(output, io.Discard)})
previousArgs := os.Args
os.Args = []string{"dws", "sheet"}
t.Cleanup(func() { os.Args = previousArgs })
}
func writeFloatImageFixture(t *testing.T, name, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func openFloatImageFixture(t *testing.T, path string) *floatImageLocalFile {
t.Helper()
local, err := openFloatImageLocalFile(path)
if err != nil {
t.Fatalf("open fixture: %v", err)
}
t.Cleanup(func() { _ = local.file.Close() })
return local
}
func TestCrossPlatformCoverageFloatImageInputValidation(t *testing.T) {
create := floatImageCommandForTest(t, "create-float-image")
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
t.Fatalf("missing create input error = %v", err)
}
setFloatImageFlags(t, create, map[string]string{"file": "a.png", "src": "/img/a"})
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
t.Fatalf("conflicting create input error = %v", err)
}
createEmptyFile := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, createEmptyFile, map[string]string{"file": " ", "src": "/img/a"})
if _, _, err := validateFloatImageCreateInput(createEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("empty create file error = %v", err)
}
fileOnly := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, fileOnly, map[string]string{"file": " a.png "})
if file, src, err := validateFloatImageCreateInput(fileOnly); err != nil || file != " a.png " || src != "" {
t.Fatalf("file-only input = %q/%q/%v", file, src, err)
}
srcOnly := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, srcOnly, map[string]string{"src": " /img/a "})
if file, src, err := validateFloatImageCreateInput(srcOnly); err != nil || file != "" || src != " /img/a " {
t.Fatalf("src-only input = %q/%q/%v", file, src, err)
}
updateBoth := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, updateBoth, map[string]string{"file": "a.png", "src": "/img/a"})
if _, _, err := validateFloatImageUpdateInput(updateBoth); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
t.Fatalf("conflicting update input error = %v", err)
}
updateEmptyFile := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, updateEmptyFile, map[string]string{"file": " "})
if _, _, err := validateFloatImageUpdateInput(updateEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("empty update file error = %v", err)
}
updateSrc := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, updateSrc, map[string]string{"src": ""})
if file, src, err := validateFloatImageUpdateInput(updateSrc); err != nil || file != "" || src != "" {
t.Fatalf("src update compatibility = %q/%q/%v", file, src, err)
}
}
func TestCrossPlatformCoverageFloatImageLocalFileValidation(t *testing.T) {
validPath := writeFloatImageFixture(t, "chart.png", "png-data")
local, err := openFloatImageLocalFile(validPath)
if err != nil {
t.Fatal(err)
}
if local.name != "chart.png" || local.mimeType != "image/png" || local.size != int64(len("png-data")) {
t.Fatalf("local metadata = %#v", local)
}
_ = local.file.Close()
if _, err := openFloatImageLocalFile(filepath.Join(t.TempDir(), "missing.png")); err == nil || !strings.Contains(err.Error(), "无法读取") {
t.Fatalf("missing file error = %v", err)
}
if _, err := openFloatImageLocalFile(t.TempDir()); err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("directory error = %v", err)
}
emptyPath := writeFloatImageFixture(t, "empty.png", "")
if _, err := openFloatImageLocalFile(emptyPath); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
t.Fatalf("empty error = %v", err)
}
validInfo, err := os.Stat(validPath)
if err != nil {
t.Fatal(err)
}
statValid := func(string) (os.FileInfo, error) { return validInfo, nil }
openFailure := errors.New("open sentinel")
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return nil, openFailure }); !errors.Is(err, openFailure) {
t.Fatalf("open error = %v", err)
}
closed, err := os.Open(validPath)
if err != nil {
t.Fatal(err)
}
_ = closed.Close()
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return closed, nil }); err == nil || !strings.Contains(err.Error(), "已打开") {
t.Fatalf("opened stat error = %v", err)
}
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(t.TempDir()) }); err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("descriptor type error = %v", err)
}
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(emptyPath) }); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
t.Fatalf("descriptor size error = %v", err)
}
}
func TestCrossPlatformCoverageFloatImageCredentialParsingAndURLPolicy(t *testing.T) {
invalidResults := []*edition.ToolResult{
nil,
{},
{Content: []edition.ContentBlock{{Type: "image", Text: "credential-secret"}}},
floatImageTextResult("{"),
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid"}`),
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"bad\nvalue","resourceUrl":"/core/api/resources/img/rid"}`),
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid","resourceUrl":"https://resource.invalid/rid"}`),
floatImageTextResult(`{"uploadUrl":"http://example.com/signed-secret","resourceId":"rid","resourceUrl":"/core/api/resources/img/rid"}`),
}
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
for index, result := range invalidResults {
_, err := parseFloatImageUploadInfo(result)
if err == nil {
t.Fatalf("invalid result %d succeeded", index)
}
if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), "credential-secret") {
t.Fatalf("invalid result %d leaked secret: %v", index, err)
}
}
top := floatImageTextResult(`{"uploadUrl":"https://upload.invalid/path?signature=secret","resourceId":"rid-top","resourceUrl":"/core/api/resources/img/top"}`)
info, err := parseFloatImageUploadInfo(top)
if err != nil || info.resourceID != "rid-top" || info.resourceURL != "/core/api/resources/img/top" {
t.Fatalf("top-level credential = %#v, %v", info, err)
}
nested := &edition.ToolResult{Content: []edition.ContentBlock{
{Type: "text", Text: " "},
{Type: "image", Text: "ignored"},
{Type: "text", Text: `{"result":{"uploadUrl":"https://upload.invalid/nested","resourceId":"rid-nested","resourceUrl":"/core/api/resources/img/nested"}}`},
}}
info, err = parseFloatImageUploadInfo(nested)
if err != nil || info.resourceID != "rid-nested" {
t.Fatalf("nested credential = %#v, %v", info, err)
}
for _, value := range []string{"", "//host/path", "/path?secret=x", "/path#fragment", "/bad\npath"} {
if validFloatImageResourceURL(value) {
t.Errorf("resource URL %q unexpectedly valid", value)
}
}
if !validFloatImageResourceURL("/core/api/resources/img/ok") || validFloatImageResourceID("") || validFloatImageResourceID("bad\x1bvalue") {
t.Fatal("resource identifier validation mismatch")
}
for _, rawURL := range []string{"not a URL", "/relative", "https://user:pass@example.com/path", "https://example.com/path#fragment", "ftp://example.com/path", "http://example.com/path"} {
if err := validateFloatImageUploadURL(rawURL); err == nil {
t.Errorf("upload URL %q unexpectedly valid", rawURL)
}
}
if err := validateFloatImageUploadURL("https://example.com/path?signature=secret"); err != nil {
t.Fatalf("HTTPS URL rejected: %v", err)
}
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
for _, rawURL := range []string{"http://localhost/path", "http://127.0.0.1/path", "http://[::1]/path"} {
if err := validateFloatImageUploadURL(rawURL); err != nil {
t.Errorf("loopback URL %q rejected: %v", rawURL, err)
}
}
if err := validateFloatImageUploadURL("http://192.0.2.1/path"); err == nil || isFloatImageLoopbackHost("not-an-ip") || isFloatImageLoopbackHost("127.0.0.2") {
t.Fatalf("non-loopback policy mismatch: %v", err)
}
}
func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) {
path := writeFloatImageFixture(t, "chart.png", "streamed-image")
t.Run("nil client", func(t *testing.T) {
local := openFloatImageFixture(t, path)
if err := putFloatImageFileWithClient(context.Background(), nil, "https://upload.invalid", local); err == nil {
t.Fatal("nil client succeeded")
}
})
t.Run("closed file", func(t *testing.T) {
local := openFloatImageFixture(t, path)
_ = local.file.Close()
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, "https://upload.invalid", local); err == nil || !strings.Contains(err.Error(), "无法读取本地文件") {
t.Fatalf("closed file error = %v", err)
}
})
t.Run("invalid request", func(t *testing.T) {
local := openFloatImageFixture(t, path)
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, ":", local); err == nil || !strings.Contains(err.Error(), "无法创建请求") {
t.Fatalf("request error = %v", err)
}
})
t.Run("transport secret redacted", func(t *testing.T) {
local := openFloatImageFixture(t, path)
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport signed-secret https://upload.invalid")
})}
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/signed-secret", local)
if err == nil || strings.Contains(err.Error(), "signed-secret") || !strings.Contains(err.Error(), "网络请求失败") {
t.Fatalf("transport error = %v", err)
}
})
t.Run("cancel", func(t *testing.T) {
local := openFloatImageFixture(t, path)
ctx, cancel := context.WithCancel(context.Background())
cancel()
client := &http.Client{Transport: floatImageRoundTripFunc(func(request *http.Request) (*http.Response, error) {
return nil, request.Context().Err()
})}
err := putFloatImageFileWithClient(ctx, client, "https://upload.invalid/path", local)
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancel error = %v", err)
}
})
t.Run("client deadline", func(t *testing.T) {
local := openFloatImageFixture(t, path)
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})}
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/path", local)
if err == nil || !strings.Contains(err.Error(), "超过传输时限") {
t.Fatalf("deadline error = %v", err)
}
})
t.Run("non-200 body redacted", func(t *testing.T) {
local := openFloatImageFixture(t, path)
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(response, "response-body-secret")
}))
defer server.Close()
err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL+"/signed-secret", local)
if err == nil || !strings.Contains(err.Error(), "HTTP 403") || strings.Contains(err.Error(), "response-body-secret") || strings.Contains(err.Error(), "signed-secret") {
t.Fatalf("status error = %v", err)
}
})
t.Run("stream success", func(t *testing.T) {
local := openFloatImageFixture(t, path)
var method, contentType, body string
var contentLength int64
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
method = request.Method
contentType = request.Header.Get("Content-Type")
contentLength = request.ContentLength
raw, _ := io.ReadAll(request.Body)
body = string(raw)
response.WriteHeader(http.StatusOK)
}))
defer server.Close()
if err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL, local); err != nil {
t.Fatal(err)
}
if method != http.MethodPut || contentType != "image/png" || contentLength != int64(len("streamed-image")) || body != "streamed-image" {
t.Fatalf("request = %s %s %d %q", method, contentType, contentLength, body)
}
})
t.Run("redirect not followed", func(t *testing.T) {
local := openFloatImageFixture(t, path)
destinationCalls := 0
destination := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { destinationCalls++ }))
defer destination.Close()
source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Location", destination.URL+"/location-secret")
response.WriteHeader(http.StatusFound)
}))
defer source.Close()
client := newFloatImageUploadClient()
if client.Timeout != floatImageUploadTimeout {
t.Fatalf("timeout = %v", client.Timeout)
}
err := putFloatImageFileWithClient(context.Background(), client, source.URL, local)
if err == nil || !strings.Contains(err.Error(), "HTTP 302") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 {
t.Fatalf("redirect error/calls = %v/%d", err, destinationCalls)
}
})
}
func TestCrossPlatformCoverageFloatImageDryRun(t *testing.T) {
path := writeFloatImageFixture(t, "chart.png", "image")
for _, format := range []string{"json", "table"} {
t.Run(format, func(t *testing.T) {
caller := &floatImageTestCaller{dryRun: true, format: format}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{
"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80",
})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run remote calls = %d", len(caller.calls))
}
if format == "json" {
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run: %v; output=%s", err, output.String())
}
arguments, _ := payload["arguments"].(map[string]any)
if payload["tool"] != "create_float_image" || arguments["src"] != floatImagePlannedResource || payload["dry_run"] != true || payload["executed"] != false {
t.Fatalf("dry-run payload = %#v", payload)
}
if _, exists := payload["preview_kind"]; exists {
t.Fatalf("request preview unexpectedly declares preview_kind: %#v", payload)
}
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> create_float_image") {
t.Fatalf("pretty dry-run = %s", output.String())
}
})
}
for _, format := range []string{"json", "table"} {
t.Run("update-"+format, func(t *testing.T) {
caller := &floatImageTestCaller{dryRun: true, format: format}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, command, map[string]string{
"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "file": path, "width": "120",
})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("update dry-run remote calls = %d", len(caller.calls))
}
if format == "json" {
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode update dry-run: %v; output=%s", err, output.String())
}
arguments, _ := payload["arguments"].(map[string]any)
if payload["tool"] != "update_float_image" || arguments["src"] != floatImagePlannedResource || arguments["floatImageId"] != "fi-1" || payload["dry_run"] != true || payload["executed"] != false {
t.Fatalf("update dry-run payload = %#v", payload)
}
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> update_float_image") {
t.Fatalf("update pretty dry-run = %s", output.String())
}
})
}
}
func TestCrossPlatformCoverageFloatImageCommandWorkflows(t *testing.T) {
path := writeFloatImageFixture(t, "chart.png", "command-image")
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Run("create src", func(t *testing.T) {
caller := &floatImageTestCaller{}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{
"node": "node-1", "sheet-id": "sheet-1", "src": "/core/api/resources/img/existing", "range": "A1", "width": "100", "height": "80",
})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].product != "sheet" || caller.calls[0].tool != "create_float_image" || caller.calls[0].args["src"] != "/core/api/resources/img/existing" {
t.Fatalf("src calls = %#v", caller.calls)
}
})
t.Run("create and update file", func(t *testing.T) {
var uploadBodies []string
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
raw, _ := io.ReadAll(request.Body)
uploadBodies = append(uploadBodies, string(raw))
response.WriteHeader(http.StatusOK)
}))
defer server.Close()
for _, commandName := range []string{"create-float-image", "update-float-image"} {
t.Run(commandName, func(t *testing.T) {
caller := &floatImageTestCaller{call: func(_ context.Context, product, tool string, _ map[string]any, index int) (*edition.ToolResult, error) {
if index == 0 {
if product != "doc" || tool != "get_doc_attachment_upload_info" {
t.Fatalf("credential route = %s/%s", product, tool)
}
return floatImageCredentialResult(server.URL+"/signed-secret", commandName == "update-float-image"), nil
}
return floatImageTextResult(`{"floatImage":{"id":"fi-ok"}}`), nil
}}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, commandName)
flags := map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path}
if commandName == "create-float-image" {
flags["range"], flags["width"], flags["height"] = "A1", "100", "80"
} else {
flags["float-image-id"], flags["width"] = "fi-1", "120"
}
setFloatImageFlags(t, command, flags)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[1].product != "sheet" || caller.calls[1].tool != strings.ReplaceAll(commandName, "-", "_") {
t.Fatalf("workflow calls = %#v", caller.calls)
}
if caller.calls[0].args["fileName"] != "chart.png" || caller.calls[0].args["mimeType"] != "image/png" || caller.calls[1].args["src"] != "/core/api/resources/img/rid-1" {
t.Fatalf("workflow args = %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil || payload["floatImage"] == nil {
t.Fatalf("final output = %s, %v", output.String(), err)
}
})
}
if len(uploadBodies) != 2 || uploadBodies[0] != "command-image" || uploadBodies[1] != "command-image" {
t.Fatalf("upload bodies = %#v", uploadBodies)
}
})
t.Run("pretty progress", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
defer server.Close()
caller := &floatImageTestCaller{format: "table", call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
if index == 0 {
return floatImageCredentialResult(server.URL, false), nil
}
return floatImageTextResult(`{"floatImage":{"id":"fi-pretty"}}`), nil
}}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
for _, marker := range []string{"[1/3]", "resourceId", "[2/3]", "[3/3]"} {
if !strings.Contains(output.String(), marker) {
t.Fatalf("pretty output missing %q: %s", marker, output.String())
}
}
})
t.Run("validation and local error", func(t *testing.T) {
caller := &floatImageTestCaller{}
installFloatImageDeps(t, caller, io.Discard)
create := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, create, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "range": "A1", "width": "100", "height": "80"})
if err := create.RunE(create, nil); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
t.Fatalf("create validation = %v", err)
}
missing := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, missing, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": filepath.Join(t.TempDir(), "missing.png"), "range": "A1", "width": "100", "height": "80"})
if err := missing.RunE(missing, nil); err == nil || !strings.Contains(err.Error(), "无法读取") {
t.Fatalf("local error = %v", err)
}
update := floatImageCommandForTest(t, "update-float-image")
if err := update.RunE(update, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateFieldsError) {
t.Fatalf("update fields error = %v", err)
}
conflict := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, conflict, map[string]string{"file": path, "src": "/img/a"})
if err := conflict.RunE(conflict, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
t.Fatalf("update conflict = %v", err)
}
missingIdentity := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, missingIdentity, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path})
if err := missingIdentity.RunE(missingIdentity, nil); err == nil || !strings.Contains(err.Error(), "float-image-id") || len(caller.calls) != 0 {
t.Fatalf("update identity error/calls = %v/%d", err, len(caller.calls))
}
})
t.Run("credential and put failures", func(t *testing.T) {
credentialCause := errors.New("credential transport")
caller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return nil, credentialCause
}}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); !errors.Is(err, credentialCause) {
t.Fatalf("credential error = %v", err)
}
secret := "credential-response-secret"
malformed := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return floatImageTextResult(secret), nil
}}
installFloatImageDeps(t, malformed, io.Discard)
command = floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); err == nil || strings.Contains(err.Error(), secret) {
t.Fatalf("malformed credential error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(response, "put-body-secret")
}))
defer server.Close()
putCaller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
}}
installFloatImageDeps(t, putCaller, io.Discard)
command = floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); err == nil || len(putCaller.calls) != 1 || strings.Contains(err.Error(), "put-body-secret") || strings.Contains(err.Error(), "signed-secret") {
t.Fatalf("put error/calls = %v/%d", err, len(putCaller.calls))
}
})
t.Run("partial final failure", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
defer server.Close()
finalCause := errors.New("final operation sentinel")
caller := &floatImageTestCaller{call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
if index == 0 {
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
}
return nil, finalCause
}}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
err := command.RunE(command, nil)
if !errors.Is(err, finalCause) || !strings.Contains(err.Error(), "resourceId=rid-1") || !strings.Contains(err.Error(), "list/get/UI") || strings.Contains(err.Error(), "signed-secret") {
t.Fatalf("partial failure = %v", err)
}
})
t.Run("update move only", func(t *testing.T) {
caller := &floatImageTestCaller{}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "range": "C5"})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].args["range"] != "C5" {
t.Fatalf("move-only calls = %#v", caller.calls)
}
})
}
func TestCrossPlatformCoverageResolveFloatImageUploadInfo(t *testing.T) {
path := writeFloatImageFixture(t, "chart.webp", "webp")
local := openFloatImageFixture(t, path)
caller := &floatImageTestCaller{call: func(ctx context.Context, product, tool string, args map[string]any, _ int) (*edition.ToolResult, error) {
if ctx == nil || product != "doc" || tool != "get_doc_attachment_upload_info" {
t.Fatalf("route/context = %s/%s/%v", product, tool, ctx)
}
if args["nodeId"] != "node-1" || args["fileName"] != "chart.webp" || args["mimeType"] != "image/webp" || args["fileSize"] != float64(4) {
t.Fatalf("credential args = %#v", args)
}
return floatImageCredentialResult("https://upload.invalid/path", false), nil
}}
installFloatImageDeps(t, caller, io.Discard)
info, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local)
if err != nil || info.resourceID != "rid-1" {
t.Fatalf("resolve info = %#v/%v", info, err)
}
cause := fmt.Errorf("timeout synthetic")
caller.call = func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return nil, cause
}
if _, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local); !errors.Is(err, cause) {
t.Fatalf("resolve error = %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
caller.call = func(ctx context.Context, _ string, _ string, _ map[string]any, _ int) (*edition.ToolResult, error) {
if !errors.Is(ctx.Err(), context.Canceled) {
t.Fatalf("credential context error = %v", ctx.Err())
}
return nil, ctx.Err()
}
if _, err := resolveFloatImageUploadInfo(cancelled, "node-1", local); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled credential error = %v", err)
}
}
func TestCrossPlatformCoverageFloatImageUploadDeadlineDocumented(t *testing.T) {
client := newFloatImageUploadClient()
if client.Timeout != 5*time.Minute || client.CheckRedirect == nil {
t.Fatalf("upload client = %#v", client)
}
}
+127 -22
View File
@@ -21,6 +21,7 @@ const (
CommandMigrationMove = "command_move"
CommandMigrationFlagExtraction = "flag_extraction"
CommandMigrationAvailability = "schema_availability_hardening"
)
// CommandMigrationManifest governs compatibility-preserving surface moves that
@@ -64,6 +65,12 @@ type CommandMigrationSchema struct {
SourceToolID string `json:"source_tool_id"`
ReplacementToolID string `json:"replacement_tool_id"`
Parameters []CommandParameterMigration `json:"parameters"`
Availability *CommandAvailabilityChange `json:"availability,omitempty"`
}
type CommandAvailabilityChange struct {
Before string `json:"before"`
After string `json:"after"`
}
type CommandParameterMigration struct {
@@ -126,14 +133,23 @@ func (m CommandMigrationManifest) Validate() error {
}
func (m CommandMigration) validate() error {
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction {
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction && m.Kind != CommandMigrationAvailability {
return fmt.Errorf("invalid kind %q", m.Kind)
}
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
}
if m.Legacy.Command == m.Replacement.Command {
return fmt.Errorf("legacy and replacement command paths must differ")
if m.Kind == CommandMigrationAvailability {
if !isExactCommandPath(m.Legacy.Command) {
return fmt.Errorf("legacy must be an exact command path rooted at dws")
}
if m.Replacement != (CommandMigrationSide{}) {
return fmt.Errorf("schema_availability_hardening must not declare replacement")
}
} else {
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
}
if m.Legacy.Command == m.Replacement.Command {
return fmt.Errorf("legacy and replacement command paths must differ")
}
}
if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
return fmt.Errorf("migration must include a non-empty trimmed reason")
@@ -141,21 +157,26 @@ func (m CommandMigration) validate() error {
if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
for label, state := range map[string]CommandMigrationState{
"legacy before": m.Legacy.Before,
"legacy after": m.Legacy.After,
"replacement before": m.Replacement.Before,
"replacement after": m.Replacement.After,
} {
states := map[string]CommandMigrationState{
"legacy before": m.Legacy.Before,
"legacy after": m.Legacy.After,
}
if m.Kind != CommandMigrationAvailability {
states["replacement before"] = m.Replacement.Before
states["replacement after"] = m.Replacement.After
}
for label, state := range states {
if err := state.validate(label); err != nil {
return err
}
}
if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
!m.Legacy.After.Present || !m.Legacy.After.Runnable {
if m.Kind != CommandMigrationAvailability &&
(!m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
!m.Legacy.After.Present || !m.Legacy.After.Runnable) {
return fmt.Errorf("legacy command must remain runnable and start visible")
}
if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden {
if m.Kind != CommandMigrationAvailability &&
(m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden) {
return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable")
}
if err := m.Schema.validate(m.Kind); err != nil {
@@ -214,6 +235,13 @@ func (m CommandMigration) validate() error {
if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt {
return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt)
}
case CommandMigrationAvailability:
if !isVisibleToHiddenAvailabilityMigration(m) && !isCompatibilityVisibleAvailabilityMigration(m) {
return fmt.Errorf("schema_availability_hardening legacy command must migrate exactly from visible to hidden or remain compatibility-visible")
}
if m.LegacyFlag != (CommandMigrationFlag{}) {
return fmt.Errorf("schema_availability_hardening must not declare legacy_flag")
}
}
return nil
}
@@ -259,9 +287,8 @@ func (f CommandMigrationFlag) validate() error {
func (s CommandMigrationSchema) validate(kind string) error {
for label, value := range map[string]string{
"product_id": s.ProductID,
"source_tool_id": s.SourceToolID,
"replacement_tool_id": s.ReplacementToolID,
"product_id": s.ProductID,
"source_tool_id": s.SourceToolID,
} {
if !isExactSchemaIdentifier(value) {
return fmt.Errorf("schema %s must be an exact identifier", label)
@@ -270,6 +297,21 @@ func (s CommandMigrationSchema) validate(kind string) error {
if s.Parameters == nil {
return fmt.Errorf("schema parameters must be an array")
}
if kind == CommandMigrationAvailability {
if s.ReplacementToolID != "" || len(s.Parameters) != 0 {
return fmt.Errorf("schema_availability_hardening must not declare a replacement tool or parameter mappings")
}
if s.Availability == nil || s.Availability.Before != "available" || s.Availability.After != "unavailable" {
return fmt.Errorf("schema_availability_hardening requires availability available to unavailable")
}
return nil
}
if !isExactSchemaIdentifier(s.ReplacementToolID) {
return fmt.Errorf("schema replacement_tool_id must be an exact identifier")
}
if s.Availability != nil {
return fmt.Errorf("%s must not declare schema availability", kind)
}
seenFrom := map[string]bool{}
seenTo := map[string]bool{}
seenConstantProperty := map[string]bool{}
@@ -344,6 +386,9 @@ func (m CommandMigration) key() string {
}
func (m CommandMigration) displayKey() string {
if m.Kind == CommandMigrationAvailability {
return fmt.Sprintf("%s %q", m.Kind, m.Legacy.Command)
}
return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
}
@@ -401,7 +446,7 @@ func AuthorizeCommandMigrations(
func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error {
commands := commandIndex(snapshot)
for _, migration := range manifest.Migrations {
if migration.Kind != CommandMigrationMove {
if migration.Kind != CommandMigrationMove && migration.Kind != CommandMigrationAvailability {
continue
}
if _, present := commands[migration.Legacy.Command]; !present {
@@ -409,7 +454,7 @@ func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigratio
}
for _, command := range snapshot.Commands {
if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") {
return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command)
return fmt.Errorf("%s legacy command %q must be a leaf", migration.Kind, migration.Legacy.Command)
}
}
}
@@ -438,17 +483,42 @@ func evaluateCommandMigrationLifecycle(
for _, approved := range authority.Migrations {
basePhase := matchCommandMigrationPhase(mergeBase, approved)
wantBase := commandMigrationBefore
if approved.State == CommandMigrationConsumed {
if approved.State == CommandMigrationConsumed && !isCompatibilityVisibleAvailabilityMigration(approved) {
wantBase = commandMigrationAfter
}
if basePhase != wantBase {
return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State)
}
proposed, exists := candidateByKey[approved.key()]
if exists && !sameCommandMigrationApproval(approved, proposed) {
if exists && !sameCommandMigrationApproval(approved, proposed) &&
!isPendingAvailabilityCompatibilityRefinement(approved, proposed) {
return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
}
currentPhase := matchCommandMigrationPhase(current, approved)
if isCompatibilityVisibleAvailabilityMigration(approved) {
if currentPhase != commandMigrationBefore {
return nil, fmt.Errorf("candidate drifted from compatibility-visible command migration %s", approved.displayKey())
}
if !exists {
return nil, fmt.Errorf("candidate must retain compatibility-visible command migration %s", approved.displayKey())
}
switch approved.State {
case CommandMigrationPending:
switch proposed.State {
case CommandMigrationPending:
continue
case CommandMigrationConsumed:
authorizations = append(authorizations, approved)
continue
}
case CommandMigrationConsumed:
if proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed compatibility-visible command migration %s back to pending", approved.displayKey())
}
authorizations = append(authorizations, approved)
continue
}
}
switch approved.State {
case CommandMigrationPending:
if !exists {
@@ -521,9 +591,40 @@ func sameCommandMigrationApproval(left, right CommandMigration) bool {
return reflect.DeepEqual(left, right)
}
func isVisibleToHiddenAvailabilityMigration(migration CommandMigration) bool {
return migration.Kind == CommandMigrationAvailability &&
migration.Legacy.Before == (CommandMigrationState{Present: true, Runnable: true}) &&
migration.Legacy.After == (CommandMigrationState{Present: true, Runnable: true, Hidden: true})
}
func isCompatibilityVisibleAvailabilityMigration(migration CommandMigration) bool {
visible := CommandMigrationState{Present: true, Runnable: true}
return migration.Kind == CommandMigrationAvailability &&
migration.Legacy.Before == visible && migration.Legacy.After == visible
}
func isPendingAvailabilityCompatibilityRefinement(approved, proposed CommandMigration) bool {
if approved.State != CommandMigrationPending || proposed.State != CommandMigrationPending ||
!isVisibleToHiddenAvailabilityMigration(approved) ||
!isCompatibilityVisibleAvailabilityMigration(proposed) {
return false
}
proposed.Legacy.After = approved.Legacy.After
return reflect.DeepEqual(approved, proposed)
}
func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
commands := commandIndex(snapshot)
legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
if migration.Kind == CommandMigrationAvailability {
if legacy == migration.Legacy.Before {
return commandMigrationBefore
}
if legacy == migration.Legacy.After {
return commandMigrationAfter
}
return commandMigrationPartial
}
replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command)
before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before
after := legacy == migration.Legacy.After && replacement == migration.Replacement.After
@@ -629,6 +730,10 @@ func commandMigrationAuthorizesChange(current, reference Snapshot, change Change
if change.Kind == "command_became_hidden" && change.Flag == "" {
return true
}
case CommandMigrationAvailability:
if change.Kind == "command_became_hidden" && change.Flag == "" {
return true
}
case CommandMigrationFlagExtraction:
if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
return true
@@ -186,6 +186,7 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
want string
}{
{"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"},
{"replacement identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ReplacementToolID = "bad/id" }, "replacement_tool_id"},
{"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"},
{"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"},
{"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"},
@@ -241,6 +242,153 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageSchemaAvailabilityMigrationLifecycle(t *testing.T) {
pending := availabilityCommandMigrationManifest(CommandMigrationPending)
consumed := availabilityCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
before := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
testCommand("dws devapp +event-list"),
)
after := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true},
)
got, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || len(got) != 1 {
t.Fatalf("availability lifecycle authorizations=%#v error=%v", got, err)
}
report, err := CompareAllWithInterfaceMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}},
FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("availability interface report compatible=%v error=%v report=%#v", report.Compatible, err, report)
}
if got, err := AuthorizeCommandMigrations(
before,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err != nil || len(got) != 0 {
t.Fatalf("candidate-added pending migration must remain inert: %#v, %v", got, err)
}
valid := pending.Migrations[0]
compatibilityVisible := cloneCommandMigration(valid)
compatibilityVisible.Legacy.After = compatibilityVisible.Legacy.Before
if err := compatibilityVisible.validate(); err != nil {
t.Fatalf("compatibility-visible availability migration must validate: %v", err)
}
for _, test := range []struct {
name string
mutate func(*CommandMigration)
want string
}{
{"legacy path", func(m *CommandMigration) { m.Legacy.Command = "dws devapp *" }, "legacy must be an exact"},
{"replacement", func(m *CommandMigration) { m.Replacement.Command = "dws devapp other" }, "must not declare replacement"},
{"absent after", func(m *CommandMigration) { m.Legacy.After = CommandMigrationState{} }, "visible to hidden or remain compatibility-visible"},
{"legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "legacy" }, "must not declare legacy_flag"},
{"missing availability", func(m *CommandMigration) { m.Schema.Availability = nil }, "requires availability"},
{"wrong availability", func(m *CommandMigration) { m.Schema.Availability.After = "available" }, "requires availability"},
{"parameter mapping", func(m *CommandMigration) {
m.Schema.Parameters = []CommandParameterMigration{{From: "cursor", To: "next-token"}}
}, "must not declare"},
} {
t.Run(test.name, func(t *testing.T) {
migration := cloneCommandMigration(valid)
if valid.Schema.Availability != nil {
change := *valid.Schema.Availability
migration.Schema.Availability = &change
}
test.mutate(&migration)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("availability migration validation error=%v, want %q", err, test.want)
}
})
}
if got := valid.displayKey(); !strings.Contains(got, CommandMigrationAvailability) {
t.Fatalf("availability display key = %q", got)
}
if got := matchCommandMigrationPhase(testSnapshot(testCommand("dws")), valid); got != commandMigrationPartial {
t.Fatalf("missing availability command phase = %q", got)
}
parent := before
parent.Commands = append(append([]Command(nil), before.Commands...), testCommand("dws devapp +event-list detail"))
if err := validateCommandMoveLegacyLeaves(parent, pending); err == nil || !strings.Contains(err.Error(), "must be a leaf") {
t.Fatalf("availability parent command error = %v", err)
}
moveSchema := commandMigrationManifest(CommandMigrationPending).Migrations[0].Schema
moveSchema.Availability = &CommandAvailabilityChange{Before: "available", After: "unavailable"}
if err := moveSchema.validate(CommandMigrationMove); err == nil || !strings.Contains(err.Error(), "must not declare schema availability") {
t.Fatalf("ordinary move availability error = %v", err)
}
}
func TestCrossPlatformCoverageCompatibilityVisibleAvailabilityMigrationLifecycle(t *testing.T) {
classicPending := availabilityCommandMigrationManifest(CommandMigrationPending)
steadyPending := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending)
steadyConsumed := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
visible := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
testCommand("dws devapp +event-list"),
)
hidden := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true},
)
references := map[string]Snapshot{"merge-base": visible, "stable": visible}
if got, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyPending); err != nil || len(got) != 0 {
t.Fatalf("pending compatibility refinement authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyPending); err != nil || len(got) != 0 {
t.Fatalf("pending compatibility receipt authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyConsumed); err != nil || len(got) != 1 {
t.Fatalf("consumed compatibility receipt authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyConsumed); err != nil || len(got) != 1 {
t.Fatalf("retained consumed compatibility receipt authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, empty, steadyPending); err != nil || len(got) != 0 {
t.Fatalf("candidate-added compatibility receipt authorizations=%#v error=%v", got, err)
}
modified := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending)
modified.Migrations[0].Reason = "Changed reason."
if _, err := AuthorizeCommandMigrations(visible, references, classicPending, modified); err == nil || !strings.Contains(err.Error(), "modified base-owned") {
t.Fatalf("modified compatibility refinement error=%v", err)
}
if _, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyConsumed); err == nil || !strings.Contains(err.Error(), "modified base-owned") {
t.Fatalf("refinement and consumption in one change error=%v", err)
}
if _, err := AuthorizeCommandMigrations(hidden, references, steadyPending, steadyPending); err == nil || !strings.Contains(err.Error(), "drifted from compatibility-visible") {
t.Fatalf("hidden compatibility command error=%v", err)
}
if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, empty); err == nil || !strings.Contains(err.Error(), "must retain compatibility-visible") {
t.Fatalf("removed consumed compatibility receipt error=%v", err)
}
if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyPending); err == nil || !strings.Contains(err.Error(), "back to pending") {
t.Fatalf("consumed compatibility receipt reverted error=%v", err)
}
}
func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) {
for _, test := range []struct {
name string
@@ -686,7 +834,10 @@ func flagExtractionCommandMigrationManifest(state string) CommandMigrationManife
func cloneCommandMigration(source CommandMigration) CommandMigration {
cloned := source
cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...)
if source.Schema.Parameters != nil {
cloned.Schema.Parameters = make([]CommandParameterMigration, len(source.Schema.Parameters))
copy(cloned.Schema.Parameters, source.Schema.Parameters)
}
for index, parameter := range source.Schema.Parameters {
if parameter.ReplacementConstant == nil {
continue
@@ -703,6 +854,34 @@ func singleCommandMigrationManifest(state string) CommandMigrationManifest {
return manifest
}
func availabilityCommandMigrationManifest(state string) CommandMigrationManifest {
return CommandMigrationManifest{
Version: CommandMigrationManifestVersion,
Migrations: []CommandMigration{{
Kind: CommandMigrationAvailability,
Legacy: CommandMigrationSide{
Command: "dws devapp +event-list",
Before: CommandMigrationState{Present: true, Runnable: true},
After: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
},
Schema: CommandMigrationSchema{
ProductID: "devapp",
SourceToolID: "devapp.shortcut_event_list",
Parameters: []CommandParameterMigration{},
Availability: &CommandAvailabilityChange{Before: "available", After: "unavailable"},
},
State: state,
Reason: "Fail closed until terminal pagination evidence is available.",
}},
}
}
func compatibilityVisibleAvailabilityCommandMigrationManifest(state string) CommandMigrationManifest {
manifest := availabilityCommandMigrationManifest(state)
manifest.Migrations[0].Legacy.After = manifest.Migrations[0].Legacy.Before
return manifest
}
func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest {
modified := source
modified.Migrations = append([]CommandMigration(nil), source.Migrations...)
+30
View File
@@ -66,6 +66,36 @@ func openSupplementServers() []ServerInfo {
Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff",
Prefixes: []string{"recruit", "job"},
},
{
ID: "edu-contact",
Name: "家校通讯录",
Endpoint: "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2",
Prefixes: []string{"edu-contact", "edu"},
},
{
ID: "edu-group",
Name: "家校群",
Endpoint: "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a",
Prefixes: []string{"edu-group"},
},
{
ID: "edu-app",
Name: "家校应用",
Endpoint: "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e",
Prefixes: []string{"edu-app"},
},
{
ID: "edu-familygroup",
Name: "家庭群",
Endpoint: "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944",
Prefixes: []string{"edu-familygroup"},
},
{
ID: "college-contact",
Name: "高校通讯录",
Endpoint: "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474",
Prefixes: []string{"college-contact"},
},
}
}
+40
View File
@@ -137,3 +137,43 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint")
}
}
func TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints(t *testing.T) {
servers := openSupplementServers()
byID := make(map[string]ServerInfo, len(servers))
for _, s := range servers {
byID[s.ID] = s
}
edu := []struct {
id string
endpoint string
prefixes []string
}{
{"edu-contact", "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2", []string{"edu-contact", "edu"}},
{"edu-group", "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a", []string{"edu-group"}},
{"edu-app", "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e", []string{"edu-app"}},
{"edu-familygroup", "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944", []string{"edu-familygroup"}},
{"college-contact", "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474", []string{"college-contact"}},
}
for _, want := range edu {
got, ok := byID[want.id]
if !ok {
t.Errorf("openSupplementServers() missing edu endpoint %q", want.id)
continue
}
if got.Endpoint != want.endpoint {
t.Errorf("%s endpoint = %q, want %q", want.id, got.Endpoint, want.endpoint)
}
if len(got.Prefixes) != len(want.prefixes) {
t.Errorf("%s prefixes length = %d, want %d", want.id, len(got.Prefixes), len(want.prefixes))
continue
}
for i, p := range want.prefixes {
if got.Prefixes[i] != p {
t.Errorf("%s prefixes[%d] = %q, want %q", want.id, i, got.Prefixes[i], p)
}
}
}
}
+12 -8
View File
@@ -14,7 +14,7 @@ usage() {
# Single source of truth for the partition set. CI runs one job per partition and
# pins its shard names to this list, so a name that appears here without a
# dispatch entry below fails closed rather than silently skipping tests.
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
APP_PARTITIONS='schema a-b c-a-l c-m-o c-p-r c-s-z c-other d-r s-z-example-fuzz'
mode="${1:-}"
partition=""
@@ -67,7 +67,8 @@ fi
schema_count=0
ab_count=0
cal_count=0
cmr_count=0
cmo_count=0
cpr_count=0
csz_count=0
cother_count=0
dr_count=0
@@ -79,7 +80,8 @@ while IFS= read -r test_name; do
Test*Schema*) schema_count=$((schema_count + 1)) ;;
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
TestCrossPlatformCoverage[M-O]*) cmo_count=$((cmo_count + 1)) ;;
TestCrossPlatformCoverage[P-R]*) cpr_count=$((cpr_count + 1)) ;;
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
TestC*) cother_count=$((cother_count + 1)) ;;
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
@@ -103,7 +105,8 @@ for spec in \
"schema:$schema_count" \
"a-b:$ab_count" \
"c-a-l:$cal_count" \
"c-m-r:$cmr_count" \
"c-m-o:$cmo_count" \
"c-p-r:$cpr_count" \
"c-s-z:$csz_count" \
"c-other:$cother_count" \
"d-r:$dr_count" \
@@ -142,14 +145,14 @@ for name in $classified; do
done
total_count="$(wc -l < "$tests" | tr -d ' ')"
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
assigned_count=$((schema_count + ab_count + cal_count + cmo_count + cpr_count + csz_count + cother_count + dr_count + sz_count))
if [ "$assigned_count" -ne "$total_count" ]; then
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
exit 1
fi
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-o=%s c-p-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmo_count" "$cpr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
if [ "$mode" = "verify" ]; then
exit 0
@@ -208,7 +211,8 @@ run_named_partition() {
schema) run_partition schema no-race "$schema_pattern" ;;
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
c-m-o) run_partition c-m-o race '^TestCrossPlatformCoverage[M-O]' "$schema_pattern" ;;
c-p-r) run_partition c-p-r race '^TestCrossPlatformCoverage[P-R]' "$schema_pattern" ;;
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
+186 -32
View File
@@ -19,17 +19,172 @@ trap cleanup EXIT HUP INT TERM
git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status"
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
{ started = 1; lines[++count] = $0 }
END {
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
}
' "$1"
}
extract_release_notes() {
git -C "$ROOT" show "$1:CHANGELOG.md" |
awk -v heading="## [$2] - " '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
'
}
release_heading() {
git -C "$ROOT" show "$1:CHANGELOG.md" |
awk -v heading="## [$2] - " 'index($0, heading) == 1 { print }'
}
changelog_without_release_notes() {
git -C "$ROOT" show "$1:CHANGELOG.md" |
awk -v heading="## [$2] - " '
index($0, heading) == 1 { print; skipped = 1; next }
skipped && /^## / { skipped = 0 }
!skipped { print }
'
}
# A pre-tag beta amendment must preserve the original seal verbatim except for
# appending newly archived fragment bodies to their existing categories. These
# helpers canonicalize the renderer's category format before comparing it.
canonicalize_fragment_notes() {
awk '
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
function trim(value) {
sub(/^[\n]+/, "", value)
sub(/[\n]+$/, "", value)
return value
}
function flush() {
if (category == "") return
value = trim(body)
if (value == "" || seen[category]++) invalid = 1
blocks[category] = value
category = ""
body = ""
}
/^### / {
flush()
candidate = substr($0, 5)
if (candidate !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) {
invalid = 1
next
}
category = candidate
next
}
{
if (category == "") {
if ($0 !~ /^[[:space:]]*$/) invalid = 1
next
}
body = body $0 "\n"
}
END {
flush()
if (invalid) exit 1
for (i = 1; i <= 6; i++) {
category = order[i]
if (category in blocks) printf "### %s\n\n%s\n\n", category, blocks[category]
}
}
' "$1"
}
merge_canonical_fragment_notes() {
awk '
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
function trim(value) {
sub(/^[\n]+/, "", value)
sub(/[\n]+$/, "", value)
return value
}
function flush() {
if (category == "") return
value = trim(body)
if (value == "") invalid = 1
if (part == 1) before[category] = value
else if (part == 2) addition[category] = value
else invalid = 1
category = ""
body = ""
}
FNR == 1 {
if (started) flush()
started = 1
part++
}
/^### / {
flush()
category = substr($0, 5)
if (category !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) invalid = 1
next
}
{ body = body $0 "\n" }
END {
flush()
if (invalid || part != 2) exit 1
for (i = 1; i <= 6; i++) {
category = order[i]
if (!(category in before) && !(category in addition)) continue
printf "### %s\n\n", category
if (category in before) printf "%s\n\n", before[category]
if (category in addition) printf "%s\n\n", addition[category]
}
}
' "$1" "$2"
}
archive_changed=false
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then
archive_changed=true
fi
if [ "$archive_changed" = true ]; then
release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2
exit 1
}
new_release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
new_release_version_count="$(printf '%s\n' "$new_release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
archival_mode=seal
if [ "$new_release_version_count" -eq 1 ]; then
release_version="$new_release_version"
else
# An amendment keeps the existing heading. Derive the one allowed target
# from the literal archive directory, then verify every move below.
release_version="$(awk -F '\t' '$1 == "R100" && NF == 3 && $3 ~ /^\.changes\/released\// { path = $3; sub(/^\.changes\/released\//, "", path); sub(/\/.*/, "", path); print path }' "$tmp_root/status" | sort -u)"
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section or one untagged beta amendment target' >&2
exit 1
}
printf '%s\n' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$' || {
printf '%s\n' 'error: only an existing beta CHANGELOG section may receive a pre-tag fragment amendment' >&2
exit 1
}
base_heading="$(release_heading "$merge_base" "$release_version")"
head_heading="$(release_heading "$head" "$release_version")"
[ "$(printf '%s\n' "$base_heading" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] && [ "$base_heading" = "$head_heading" ] || {
printf '%s\n' 'error: beta fragment amendment requires one unchanged pre-existing CHANGELOG heading' >&2
exit 1
}
changelog_without_release_notes "$merge_base" "$release_version" >"$tmp_root/base-changelog-without-amendment"
changelog_without_release_notes "$head" "$release_version" >"$tmp_root/head-changelog-without-amendment"
if ! cmp -s "$tmp_root/base-changelog-without-amendment" "$tmp_root/head-changelog-without-amendment"; then
printf '%s\n' 'error: beta fragment amendment may change only its existing beta CHANGELOG section' >&2
exit 1
fi
if git -C "$ROOT" rev-parse --verify --quiet "refs/tags/v$release_version^{commit}" >/dev/null; then
printf 'error: beta fragment amendment is forbidden after tag v%s exists\n' "$release_version" >&2
exit 1
fi
archival_mode=beta-amendment
fi
# The archive directory is matched as a literal prefix, never as a regex:
# interpolating the version into one would make `.` match any character, so
# `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break
@@ -47,7 +202,7 @@ if [ "$archive_changed" = true ]; then
{ invalid = 1 }
END { exit !(changelog && moved > 0 && !invalid) }
' "$tmp_root/status"; then
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal PR' >&2
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal or untagged beta-amendment PR' >&2
exit 1
fi
source_changes="$tmp_root/source-changes"
@@ -63,17 +218,25 @@ if [ "$archive_changed" = true ]; then
esac
done
"$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes"
git -C "$ROOT" show "$head:CHANGELOG.md" |
awk -v heading="## [$release_version] - " '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' >"$tmp_root/actual-section"
case "$release_version" in
*-beta.*)
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
;;
*)
if [ "$archival_mode" = beta-amendment ]; then
extract_release_notes "$merge_base" "$release_version" >"$tmp_root/base-notes"
extract_release_notes "$head" "$release_version" >"$tmp_root/head-notes"
if ! canonicalize_fragment_notes "$tmp_root/base-notes" >"$tmp_root/base-notes.canonical" ||
! canonicalize_fragment_notes "$tmp_root/expected-notes" >"$tmp_root/amendment-notes.canonical" ||
! merge_canonical_fragment_notes "$tmp_root/base-notes.canonical" "$tmp_root/amendment-notes.canonical" >"$tmp_root/expected-notes.merged" ||
! canonicalize_fragment_notes "$tmp_root/head-notes" >"$tmp_root/actual-notes.canonical"; then
printf '%s\n' 'error: beta fragment amendment requires canonical rendered fragment categories' >&2
exit 1
fi
normalize_notes "$tmp_root/expected-notes.merged" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes.canonical" >"$tmp_root/actual-notes.normalized"
else
extract_release_notes "$head" "$release_version" >"$tmp_root/actual-section"
case "$release_version" in
*-beta.*)
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
;;
*)
stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
[ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
@@ -88,22 +251,13 @@ if [ "$archive_changed" = true ]; then
found { print }
/^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
;;
esac
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
{ started = 1; lines[++count] = $0 }
END {
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
}
' "$1"
}
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
;;
esac
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
fi
if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then
printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2
printf '%s\n' 'error: release CHANGELOG section does not exactly match the rendered active release fragments' >&2
diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true
exit 1
fi
@@ -86,6 +86,54 @@
},
"state": "pending",
"reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command."
},
{
"kind": "schema_availability_hardening",
"legacy": {
"command": "dws devapp +event-list",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"schema": {
"product_id": "devapp",
"source_tool_id": "devapp.shortcut_event_list",
"parameters": [],
"availability": {"before": "available", "after": "unavailable"}
},
"state": "pending",
"reason": "Fail closed until event-list proves authoritative terminal pagination for both non-empty and zero-result live fixtures."
},
{
"kind": "schema_availability_hardening",
"legacy": {
"command": "dws devapp +member-add",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"schema": {
"product_id": "devapp",
"source_tool_id": "devapp.shortcut_member_add",
"parameters": [],
"availability": {"before": "available", "after": "unavailable"}
},
"state": "pending",
"reason": "Fail closed until a safe second-member fixture proves exact role readback and rollback for member addition."
},
{
"kind": "schema_availability_hardening",
"legacy": {
"command": "dws devapp +member-remove",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"schema": {
"product_id": "devapp",
"source_tool_id": "devapp.shortcut_member_remove",
"parameters": [],
"availability": {"before": "available", "after": "unavailable"}
},
"state": "pending",
"reason": "Fail closed until a safe second-member fixture proves exact removal readback and restoration."
}
]
}
@@ -13,6 +13,79 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
func TestCrossPlatformCoverageSchemaAvailabilityMigrationNormalizesOnlyAvailability(t *testing.T) {
baseline := baselineContract()
current := cloneContract(baseline)
mutateTool(&current, func(tool *toolSchema) { tool.Availability = "unavailable" })
migration := interfacesnapshot.CommandMigration{
Kind: interfacesnapshot.CommandMigrationAvailability,
Legacy: interfacesnapshot.CommandMigrationSide{
Command: "dws doc create",
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
},
Schema: interfacesnapshot.CommandMigrationSchema{
ProductID: "doc",
SourceToolID: "doc.create",
Parameters: []interfacesnapshot.CommandParameterMigration{},
Availability: &interfacesnapshot.CommandAvailabilityChange{Before: "available", After: "unavailable"},
},
State: interfacesnapshot.CommandMigrationPending,
Reason: "Reviewed fail-closed availability hardening.",
}
normalized, err := normalizeSchemaCommandMigrations(baseline, current, []interfacesnapshot.CommandMigration{migration})
if err != nil {
t.Fatal(err)
}
if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "unavailable" {
t.Fatalf("normalized availability = %q", got)
}
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
t.Fatalf("availability hardening failures = %v", failures)
}
unrelated := cloneContract(current)
mutateTool(&unrelated, func(tool *toolSchema) { tool.Risk = "high" })
normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, []interfacesnapshot.CommandMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, "changed risk") {
t.Fatalf("unrelated risk drift was hidden: %q", failures)
}
alreadyAfter := cloneContract(current)
if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, []interfacesnapshot.CommandMigration{migration}); err != nil {
t.Fatalf("consumed merge-base availability should remain inert: %v", err)
}
wrongPath := cloneContract(current)
mutateTool(&wrongPath, func(tool *toolSchema) { tool.PrimaryCLIPath = "doc other" })
normalized, err = normalizeSchemaCommandMigrations(baseline, wrongPath, []interfacesnapshot.CommandMigration{migration})
if err != nil {
t.Fatal(err)
}
if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "available" {
t.Fatalf("wrong-path migration changed availability to %q", got)
}
wrong := cloneContract(current)
mutateTool(&wrong, func(tool *toolSchema) { tool.Availability = "available" })
if _, err := normalizeSchemaCommandMigrations(baseline, wrong, []interfacesnapshot.CommandMigration{migration}); err == nil ||
!strings.Contains(err.Error(), "does not match Schema availability") {
t.Fatalf("wrong availability error = %v", err)
}
compatibilityVisible := migration
compatibilityVisible.Legacy.After = compatibilityVisible.Legacy.Before
if _, err := normalizeSchemaCommandMigrations(baseline, current, []interfacesnapshot.CommandMigration{compatibilityVisible}); err != nil {
t.Fatalf("compatibility-visible availability hardening must authorize the exact Schema transition: %v", err)
}
if _, err := normalizeSchemaCommandMigrations(baseline, baseline, []interfacesnapshot.CommandMigration{compatibilityVisible}); err == nil ||
!strings.Contains(err.Error(), "does not match Schema availability") {
t.Fatalf("compatibility-visible receipt must not authorize unchanged Schema availability: %v", err)
}
}
func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) {
baseline := schemaCommandMigrationContract(false)
current := schemaCommandMigrationContract(true)
+24
View File
@@ -815,6 +815,12 @@ var reviewedConstraintTransition = map[string]map[string]string{
"doc/doc.shortcut_import": {
`{"require_one_of":[["folder","workspace"]]}`: "",
},
// PR #1105 adds local --file as an alternative to the historically required
// --src input. Every historical --src invocation remains valid; publishing
// both groups makes the final Schema express the runtime's exact-one rule.
"sheet/sheet.create_float_image": {
"": `{"mutually_exclusive":[["file","src"]],"require_one_of":[["file","src"]]}`,
},
}
func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool {
@@ -1869,6 +1875,24 @@ func normalizeSchemaCommandMigrations(
normalizedProduct := normalized.Products[migration.Schema.ProductID]
normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID]
switch migration.Kind {
case interfacesnapshot.CommandMigrationAvailability:
change := migration.Schema.Availability
if change != nil && oldTool.Availability == change.After && newSource.Availability == change.After {
continue
}
if change == nil || oldTool.Availability != change.Before || newSource.Availability != change.After {
return schemaContract{}, fmt.Errorf(
"approved availability hardening %q does not match Schema availability %q -> %q",
migration.Legacy.Command,
oldTool.Availability,
newSource.Availability,
)
}
if newSource.PrimaryCLIPath != legacyPath {
continue
}
normalizedTool.Availability = newSource.Availability
case interfacesnapshot.CommandMigrationMove:
if newSource.PrimaryCLIPath != replacementPath {
continue
+30
View File
@@ -992,6 +992,36 @@ func TestCrossPlatformCoverageSchemaCompatReviewedConstraintTransition(t *testin
}
})
}
const sheetToolPath = "sheet/sheet.create_float_image"
const sheetTarget = `{"mutually_exclusive":[["file","src"]],"require_one_of":[["file","src"]]}`
sheetOldTool := toolSchema{}
sheetNewTool := sheetOldTool
sheetNewTool.Constraints = sheetTarget
if !compatibleReviewedConstraintTransition(sheetToolPath, sheetOldTool, sheetNewTool) {
t.Fatal("reviewed float-image local-file transition must be accepted")
}
if failures := checkToolCompatibility(sheetToolPath, sheetOldTool, sheetNewTool); len(failures) != 0 {
t.Fatalf("reviewed float-image local-file transition failed: %v", failures)
}
for _, test := range []struct {
name string
path string
old string
new string
}{
{name: "float image unlisted tool", path: "sheet/sheet.other", new: sheetTarget},
{name: "float image unlisted source", path: sheetToolPath, old: `{"require_one_of":[["src"]]}`, new: sheetTarget},
{name: "float image unlisted target", path: sheetToolPath, new: `{"require_one_of":[["file","src"]]}`},
} {
t.Run(test.name, func(t *testing.T) {
if compatibleReviewedConstraintTransition(test.path, toolSchema{Constraints: test.old}, toolSchema{Constraints: test.new}) {
t.Fatal("unreviewed float-image constraint transition unexpectedly passed")
}
})
}
}
// Clearing a property through the reviewed mapping exclusion table is the one
@@ -112,6 +112,26 @@ coverage:
multi_skill: dingtalk-misc
multi_refs:
- references/recruit.md
- mono: edu-app
multi_skill: dingtalk-misc
multi_refs:
- references/edu-app.md
- mono: edu-contact
multi_skill: dingtalk-misc
multi_refs:
- references/edu-contact.md
- mono: edu-group
multi_skill: dingtalk-misc
multi_refs:
- references/edu-group.md
- mono: college-contact
multi_skill: dingtalk-misc
multi_refs:
- references/college-contact.md
- mono: edu-familygroup
multi_skill: dingtalk-misc
multi_refs:
- references/edu-familygroup.md
omit_coverage:
- mono: simple
+7
View File
@@ -98,6 +98,11 @@ cli_version: ">=1.0.15"
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) |
| `edu-app` | 家校应用(教育版):家校任务、班级消息摘要等教育场景应用能力 | [edu-app.md](./references/products/edu-app.md) |
| `edu-contact` | 家校通讯录(教育版):学校组织架构、班级列表等教育场景通讯录能力 | [edu-contact.md](./references/products/edu-contact.md) |
| `edu-group` | 家校群(师生群):班级群/师生群的查询、创建、解散 | [edu-group.md](./references/products/edu-group.md) |
| `edu-familygroup` | 家庭群:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制 | [edu-familygroup.md](./references/products/edu-familygroup.md) |
| `college-contact` | 高校通讯录:高校组织架构/院系部门管理(查询/创建/更新/删除)/师生员工管理(查询/添加/移除/变更类型与部门/激活短信)/通讯录搜索/概览统计/升级 | [college-contact.md](./references/products/college-contact.md) |
| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -167,6 +172,8 @@ cli_version: ">=1.0.15"
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
| `todo` | `task delete` | 删除待办 |
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
| `college-contact` | `dept delete` | 删除高校部门,不可恢复 |
| `college-contact` | `employee remove` | 移除高校员工,不可恢复 |
### 确认流程
```
@@ -0,0 +1,135 @@
# 高校通讯录 (college-contact) 命令参考
## 命令总览
### dept (部门管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 |
| `dept get-detail` | 查询部门详情 | `--dept-id` |
| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` |
| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` |
| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` |
| `dept update` | 更新部门 | `--dept-id`, `--dept-type` |
| `dept delete` | 删除部门 ⚠️ | `--dept-id` |
| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` |
| `dept overview` | 查询高校概览统计 | 无 |
### employee (员工管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `employee get-detail` | 查询员工详情 | `--staff-id` |
| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` |
| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) |
| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` |
| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` |
| `employee send-active-sms` | 发送激活短信 | `--dept-id` |
| `employee list-employees` | 查询部门员工列表 | `--dept-id` |
| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` |
| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` |
| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 |
| `employee start-upgrade` | 启动高校通讯录升级 | 无 |
### alumni (校友管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` |
| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` |
| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` |
| `alumni query` | 查询单个校友详情 | `--staff-id` |
| `alumni search` | 搜索校友 | `--keyword` |
| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` |
| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` |
| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` |
| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` |
| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` |
| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) |
| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) |
| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) |
| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` |
| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) |
| `alumni create-group` | 创建校友群 | `--alumni-dept-id` |
| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` |
| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 |
| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` |
| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) |
### graduate (毕业年级管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 |
| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) |
| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` |
| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) |
| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) |
| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 |
| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` |
| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) |
| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) |
| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) |
| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) |
| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` |
| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) |
### group (规则管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) |
| `group get-group-rule-schedule` | 查询规则调度 | 无参数 |
| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) |
| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) |
| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` |
| `group enable-group-rule` | 启用规则 | `--rule-id` |
| `group disable-group-rule` | 停用规则 | `--rule-id` |
| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) |
| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 |
## 常用参数说明
- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工)
- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业)
- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表
- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序
- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false)
## 意图判断
用户说"高校架构/组织架构/学院/系/部门" → dept 子命令
用户说"搜人/找某某老师/找某某同学" → `dept search`
用户说"师生/教职工/学生/员工/辅导员" → employee 子命令
用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms`
用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade`
用户说"校友/校友会/校友部门/添加校友" → alumni 子命令
用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令
用户说"群规则/建群规则/自动建群" → group 子命令
## 核心工作流
1. 查标准架构 → `dept get-standard-structure`(提取 deptId)
2. 查看部门详情 → `dept get-detail --dept-id <deptId>`
3. 查看部门员工 → `employee list-employees --dept-id <deptId>`
4. 查看员工详情 → `employee get-detail --staff-id <staffId>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id |
| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids |
| `dept create` | `deptId` | update/delete 的 --dept-id |
| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id |
| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id |
| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id |
| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id |
## 危险操作
- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。
+13
View File
@@ -595,6 +595,7 @@ Flags:
用户说"恢复文件/还原删除的文件/从回收站恢复/还原回收站文件" → `recycle restore`
用户说"给文档授权/分享权限" → `permission add`
用户说"授权并通知对方/加权限后告知他/通知一下被授权的人" → `permission add --members ... --notify`(未提通知需求时不传 `--notify`)
用户说"权限设置/权限模式/分享范围/水印等策略配置" → `permission get-setting`
用户说"公开文件/互联网公开/设置公开/让互联网所有人可访问" → `publish set`
用户说"关闭公开/取消公开/取消互联网访问" → `publish unset`
用户说"查看公开状态/是否公开/发布状态" → `publish get`
@@ -724,6 +725,7 @@ Usage:
dws drive permission update --node <ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"}]'
dws drive permission list --node <ID>
dws drive permission list --node <ID> --limit 50 --next-token <上次返回的 nextToken>
dws drive permission get-setting --node <ID>
dws drive permission remove --node <ID> --users uid1
dws drive permission remove --node <ID> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'
Flags:
@@ -754,6 +756,17 @@ Flags:
> - 单次请求最多 30 个成员,超出请分批调用
> - list 命令底层一次性返回全量成员后在内存中按 pageSize 分页,当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页
`get-setting` 返回节点权限配置(不是成员清单):`permissionMode`(INHERITED 继承上级 / INDEPENDENT 独立管理)、`shareScope`(可见范围与链接分享设置)、`policies`(水印、组织外分享、添加成员门槛等策略列表)。查询协作者清单仍用 `permission list`。
get-setting 返回字段说明:
- `permissionMode` — INHERITED(继承上级)/ INDEPENDENT(独立管理),未知时为 null
- `shareScope` — `visibility`(PRIVATE/ORGANIZATION/PUBLIC);`partnerIncluded`、`defaultRole`、`canSearch`、`canRecommend` 仅 ORGANIZATION 有意义;`linkShare`(仅开启链接分享时返回):`requirePassword`(密码明文不返回)、`expireAt`/`expireDays`(未设置为 null)、`forCurrentNode`
- `policies[]` — 每项含 `code`(策略码)、`name`/`description`(中文名与值语义说明,随行必带)、`value`(当前值)、`disabledValues`(不可设置取值列表)、`allowedValues`(可设置值域,与 disabledValues 互斥);未下发或不支持的策略不返回;`node_spread_scope` 仅文件夹类节点返回
- `disabledValues[]` — 每项含 `value`(被禁档位取值,与 value 同一值域)与 `reason`(服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null);恒返回,无被禁档位时为空数组;示例:`{"value": "READER_AND_ABOVE", "reason": "企业安全策略要求不可低于可下载角色"}`
- `value` 按策略分型:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;member_invite、comment 为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE(无 NOBODY);node_spread、online_content_copy 为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE 或 NOBODY(无 READER_AND_ABOVE);node_spread_scope 为 ALL_NODES(限制对所有文档生效)/ PREVIEWABLE_ONLY(仅对可预览的文档生效)
- `name`/`description` 示例(文案与产品权限设置页一致):external_share「添加企业外协作者」:是否允许添加企业外的人为协作者(ENABLED=允许,DISABLED=禁止);node_spread「谁可以下载、创建副本、打印」:允许哪些角色及以上的用户下载、创建副本、打印;NOBODY=所有人禁止下载、创建副本、打印;node_move_forbidden「禁止移动」:是否禁止移动到其他知识库或团队共享文件夹(ENABLED=禁止移动,DISABLED=允许移动)
- 方向语义:NOBODY=该操作对所有人禁止;XXX_AND_ABOVE=不低于该角色才允许
### 文件互联网公开发布
管理文件的互联网公开发布状态。公开后任何人通过链接即可访问,无需登录钉钉。操作者需要是文件的管理员或拥有者。
+191
View File
@@ -0,0 +1,191 @@
# 家校应用 (edu-app) 命令参考
## 命令总览
### message (消息管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` |
> `--target-role`: guardian(家长) / student(学生)
> `--status`: 0(未处理) / 1(已处理)
### task (任务管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) |
| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) |
| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) |
> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA
### report (成绩单管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) |
| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) |
| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` |
| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` |
| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` |
### notice (通知管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `notice create` | 创建并发布通知 | `--identifer`, `--content` |
| `notice get` | 查询通知详情 | `--notice-id` |
| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) |
| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` |
| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` |
| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` |
| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` |
> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier`;建议格式 `orgId-staffId-UUID`
> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收
> `notice list-by-teacher --status` / `notice list-by-student --status` 用于筛选通知状态
### circle (班级圈)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` |
> `--target-role`: guardian(家长视角) / student(学生视角)
> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。
### card (打卡管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `card update` | 修改打卡任务的标题或内容(仅老师且为创建者) | `--card-id`, `--identifier`, (`--title` 或 `--content` 至少一个) |
| `card end` | 提前结束打卡任务(仅老师且为创建者) | `--card-id` |
| `card list` | 查询孩子/本人打卡列表(含进行中与已完结) | `--status` |
| `card user-statistic` | 查询班级已完成/未完成人员(仅老师/班主任) | `--card-id`, `--task-code`, `--class-id` |
| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` |
> `--status`: FINISH(已完结) / UNFINISH(进行中)
> `--identifier` 建议格式 `orgId-staffId-UUID`,用于幂等去重
> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断
> `card finish-info` 当 `targetRole=guardian` 时,可传 `--student-id` 指定查看某个孩子的进度
### homework (作业管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` |
| `homework get` | 查询作业详情 | `--homework-id` |
| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) |
| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` |
| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` |
| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` |
| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` |
| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` |
| `homework submit` | 提交作业 | `--hw-content-detail-id` |
| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` |
| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` |
> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题
> `--submit-types` / `--hw-type` / `--publish-type` 控制提交方式与作业类型
> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id`
### diploma (奖状管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` |
| `diploma get` | 查询奖状详情 | `--diploma-id` |
| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) |
| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` |
| `diploma detail` | 查询奖状接收详情 | `--diploma-id` |
| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` |
| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` |
| `diploma read` | 标记奖状为已读 | `--diploma-id` |
| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` |
> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板
## 危险操作
以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3):
| 命令 | 后果 |
|------|------|
| `notice delete --notice-id <id> --yes` | 删除通知,家长/学生侧不可恢复 |
| `homework delete --homework-id <id> --yes` | 删除作业及其提交记录 |
| `diploma delete --diploma-id <id> --yes` | 删除已颁发的奖状 |
其余命令均为读或普通写操作,不需要 `--yes`。
## 意图判断
用户说"消息/消息摘要" → message summary-list
用户说"家校任务/待办任务" → task 子命令
用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`)
用户说"成绩/成绩单" → report 子命令
用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`)
用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`)
用户说"班级圈/成长记录/学生动态" → circle posts
用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令
关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源)
关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结)
关键区分: diploma(奖状/表彰) vs report(成绩单)
老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。
## 核心工作流
### 老师场景
1. 查看发布的任务 → `task publish-list --need-statistic -f json`
2. 查看某班全部任务 → `task all-list --biz-id <classId>`
3. 查看成绩单 → `report by-teacher --status 1`
4. 查看班级成绩明细 → `report by-class --report-id <id> --class-id <classId>`
5. 修改打卡标题/内容 → `card update --card-id <cardId> --identifier <id> --title "新标题"`
6. 提前结束打卡 → `card end --card-id <cardId>`
7. 查看某班打卡完成情况 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId> --finish`
8. 查看某班未打卡人员 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId>`
9. 查看某打卡完成进度 → `card finish-info --card-id <cardId> --card-biz-id <cardBizId>`
10. 发布作业 → `homework create --identifier <orgId-staffId-UUID> --hw-content "第三章习题" --class-ids <classId>`
11. 查看作业提交统计 → `homework submit-statistics --homework-id <id> --class-id <classId>`
12. 批改作业写评语 → `homework create-comment --hw-content-detail-id <id> --comment "写得很好"`
13. 删除作业 → `homework delete --homework-id <id> --yes`
14. 发布通知 → `notice create --identifer <orgId-staffId-UUID> --content "明天放假" --class-ids <classId> --is-signed true`
15. 查看通知签收情况 → `notice confirm-status --notice-id <id> --class-id <classId>`
16. 删除通知 → `notice delete --notice-id <id> --yes`
17. 颁发奖状 → `diploma create --identifier <orgId-staffId-UUID> --content "三好学生" --user-name <老师姓名> --class-ids <classId>`
18. 查看奖状阅读统计 → `diploma statistics --diploma-id <id>`
19. 删除奖状 → `diploma delete --diploma-id <id> --yes`
### 家长场景
1. 查看孩子待办 → `task student-list --students '[{"userId":"<uid>","bizId":"<classId>"}]'`
2. 确认通知 → `notice confirm --notice-id <id> --student-id <uid>`
3. 查看孩子收到的通知 → `notice list-by-student --student-id <uid> --class-id <classId>`
4. 查看孩子班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role guardian`
5. 查看孩子进行中打卡 → `card list --status UNFINISH`
6. 查看孩子某打卡进度 → `card finish-info --card-id <cardId> --card-biz-id <cardBizId>`
7. 查看孩子作业列表 → `homework list-by-student --student-id <uid> --class-id <classId> --user-name <家长姓名>`
8. 查看孩子收到的奖状 → `diploma list-by-student --student-id <uid> --class-id <classId>`
### 学生场景
1. 查看自己的班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role student`
2. 提交作业 → `homework submit --hw-content-detail-id <id> --content "已完成"`
3. 查看某份作业详情 → `homework student-detail --homework-id <id> --student-id <uid> --class-id <classId>`
4. 标记奖状已读 → `diploma read --diploma-id <id>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id |
| `dws edu-contact class students` | `userId` | task student-list 的 students.userId |
| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid |
| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id |
| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id |
| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id |
| `task publish-list` | `taskCode` | card user-statistic 的 --task-code |
| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id |
| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id |
| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id |
| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id |
@@ -0,0 +1,74 @@
# 家校通讯录 (edu-contact) 命令参考
## 命令总览
### school (学校/组织管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `school roles` | 查询用户在组织内的身份 | 无 |
| `school structure` | 查询学校组织架构 | 无 |
| `school periods` | 查询学校学段信息 | 无 |
| `school type` | 查询学校组织类型 | 无 |
| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) |
| `school class-list` | 查询学校所有班级列表 | 无 |
### class (班级管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `class detail` | 查询班级详情 | `--dept-id` |
| `class students` | 查询班级学生信息 | `--dept-id` |
| `class teachers` | 查询班级老师列表 | `--dept-id` |
| `class same-name` | 查询班级内同名学生 | `--dept-id` |
| `class user-role` | 查询用户在班级内的角色 | `--dept-id` |
| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` |
| `class headmaster` | 根据班级名查询班主任 | `--class-name` |
| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` |
| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` |
| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` |
| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` |
| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` |
| `class update-info` | 更新班级信息 | `--class-id` |
| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` |
| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` |
| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` |
| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` |
| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` |
| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` |
### family (家庭关系查询)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `family children` | 查询家长的孩子信息 | 无 |
| `family parents` | 查询学生的家长信息 | 无 |
### teacher (教师管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `teacher classes` | 查询老师管理的班级列表 | 无 |
| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) |
## 意图判断
用户说"学校/组织/学段/组织架构" → school 子命令
用户说"班级/学生/教师/班主任" → class 子命令
用户说"家长/孩子/家庭关系" → family 子命令
用户说"任教/科目" → teacher update-course
## 核心工作流
1. 查询角色 → `school roles`
2. 查看班级列表 → `school class-list`(提取 deptId)
3. 查看班级详情 → `class detail --dept-id <deptId>`
4. 查看学生列表 → `class students --dept-id <deptId>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `school class-list` | `deptId` | class 子命令的 --dept-id |
| `class students` | `userId` | update-student/delete-students 的 --student-user-id |
| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id |
@@ -0,0 +1,68 @@
# 家庭群 (edu-familygroup) 命令参考
## 命令总览
### group (家庭群查询)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` |
| `group list-children` | 查询家长绑定的孩子列表 | `--uid` |
### manage (家庭群管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `manage create` | 创建家庭群 | `--uid`, `--children` |
| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` |
| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) |
| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` |
## 意图判断
用户说"家庭群存在/有没有家庭群" → group check-exists
用户说"孩子列表/我的孩子" → group list-children
用户说"创建家庭/建群" → manage create
用户说"邀请家长/拉家长入群" → manage invite-parent
用户说"添加孩子/加娃" → manage add-child
用户说"应用权限/小天地/学习视频" → manage toggle-app
## 核心工作流
1. 检查家庭群是否存在 → `group check-exists --uid <uid> --group-name <name>`
2. 如不存在,创建家庭群 → `manage create --uid <uid> --children '<json>'`
3. 查看已绑定孩子 → `group list-children --uid <uid>`
4. 邀请其他家长 → `manage invite-parent --org-id <orgId> --uid <uid> --mobile <phone>`
5. 添加孩子 → `manage add-child --org-id <orgId> --uid <uid> --name <name> --mobile <phone>`
6. 管理应用权限 → `manage toggle-app --org-id <orgId> --uid <uid> --child-staff-id <id> --app-type XIAOTIANDI --open true`
## 参数说明
### manage create --children 格式
```json
[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]
```
每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。
### manage add-child --students 格式
```json
[{"schoolOrgId":111,"studentStaffId":"stu001"}]
```
每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。
### manage toggle-app --app-type 可选值
- `XIAOTIANDI`:小天地(学生圈)
- `LEARNING_VIDEO`:学习视频
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id |
| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id |
| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid |
@@ -0,0 +1,55 @@
# 家校群 (edu-group) 命令参考
## 命令总览
### student-group (师生群管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `student-group info` | 查询班级师生群信息 | `--dept-id` |
| `student-group exists` | 检查是否已创建师生群 | `--dept-id` |
| `student-group members` | 查询师生群成员列表 | `--dept-id` |
| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` |
| `student-group conversation` | 查询班级群会话详情 | `--dept-id` |
| `student-group create` | 创建班级师生群 | `--dept-id` |
| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` |
### class-group (班级群会话管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` |
| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` |
| `class-group exists` | 检查班级群是否存在 | `--dept-id` |
| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` |
### batch (批量操作)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` |
| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` |
| `batch create-student-groups` | 批量创建师生群 | 无 |
## 意图判断
用户说"师生群/学生群" → student-group 子命令
用户说"班级群/群会话" → class-group 子命令
用户说"批量/一键操作" → batch 子命令
关键区分: student-group(师生群) vs class-group(班级群会话管理)
## 核心工作流
1. 检查群是否存在 → `student-group exists --dept-id <deptId>`
2. 如不存在,创建 → `student-group create --dept-id <deptId>`
3. 查看成员 → `student-group members --dept-id <deptId>`
4. 获取会话ID → `class-group conversation-id --dept-id <deptId>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id |
| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid |
| `batch check-student-group` | 未创建群的班级 | batch create-student-groups |
+3 -3
View File
@@ -51,7 +51,7 @@
| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 |
| 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 |
| 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 |
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [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 当成当前最终值 |
@@ -80,7 +80,7 @@
| [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` |
| [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` |
| [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` |
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
| [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` |
@@ -237,7 +237,7 @@ Flags:
| "搜索公式文本" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<公式片段>" --match-formula` |
| "正则搜索 / 不区分大小写" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<regexp>" --use-regexp --match-case=false` |
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1 --file <图片路径>` |
| "创建浮动图片" | 先 `dws sheet media-upload --node <nodeId或URL> --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --src "<resourceUrl>" --range A1 --width <宽> --height <高>` |
| "创建浮动图片" | `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --file <图片路径> --range A1 --width <宽> --height <高>` |
## URL 与 ID 前置
@@ -17,7 +17,7 @@
### 浮动图片
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src`
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
@@ -71,7 +71,11 @@ Flags:
Usage:
dws sheet create-float-image [flags]
Example:
# 先上传图片获取 resourceUrl
# 直接上传本地图片并创建浮动图片
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--file ./chart.png --range A1 --width 400 --height 300
# 高级用法:先上传图片获取 resourceUrl
dws sheet media-upload --node <NODE_ID> --file ./chart.png
# 输出: resourceUrl: /core/api/resources/img/xxxx...
@@ -85,7 +89,8 @@ Example:
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
--file string 本地图片文件路径,与 --src 二选一
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
--width int 图片宽度,像素,正整数 (必填)
--height int 图片高度,像素,正整数 (必填)
@@ -94,7 +99,8 @@ Flags:
```
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `--width` / `--height` 为必填,单位像素,必须为正整数
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
@@ -138,13 +144,18 @@ Example:
# 调整尺寸
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
# 直接用本地图片替换
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
--file ./replacement.png
# 高级用法:通过已上传的 resourceUrl 替换
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
--src "/core/api/resources/img/xxxx..."
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
--file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
--range string 新的锚点单元格,A1 表示法
--width int 新的图片宽度,像素
@@ -153,7 +164,7 @@ Flags:
--offset-y int 新的垂直偏移量,像素
```
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。
`--float-image-id` 可通过 `list-float-images` 获取。
### 删除浮动图片
@@ -210,6 +221,16 @@ dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B1" \
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A2" \
--values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
# ── 工作流 11: 创建或替换浮动图片 ──
# 从本地图片直接创建
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--file ./chart.png --range A1 --width 400 --height 300 -f json
# 从本地图片直接替换已有浮动图
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--float-image-id <FI_ID> --file ./replacement.png -f json
```
## 上下文传递
@@ -232,11 +253,11 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
- `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
- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src`
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file`
- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
@@ -72,6 +72,7 @@ dws drive +publish-unset --node <dentryUuid>
| 意图 | managed leaf |
|---|---|
| 查看成员权限 | `drive permission list` |
| 查询节点权限设置(权限模式/分享范围/策略) | `permission get-setting` |
| 添加、修改、移除成员 | `permission add` / `update` / `remove` |
| 转移所有者 | `permission transfer-owner` |
| 查看可申请权限和审批人 | `permission apply-info` |
@@ -79,6 +80,8 @@ dws drive +publish-unset --node <dentryUuid>
只在意图命中时读取一个精确 leaf Schema。成员变更、转移所有者、发起申请和公开状态变更必须明确节点、用户、角色与影响范围。转移所有者时,在构造最终命令前必须让用户分别明确决定 `--reserve-role <MANAGER|EDITOR|DOWNLOADER|READER|NONE>` 和 `--recursive=<true|false>`;Agent 不得根据默认值、对象类型或便捷性自行选择任一项。两项决策与目标、新所有者均明确后,才按 Runtime confirmation 构造首次正式调用。
`permission get-setting` 返回 `permissionMode`(INHERITED/INDEPENDENT,未知时为 null)、`shareScope`(可见范围与链接分享,密码明文不返回;`partnerIncluded`、`defaultRole` 等仅 ORGANIZATION 有意义,`linkShare` 仅开启链接分享时返回)和 `policies[]`(code/name/description/value/disabledValues/allowedValues;name/description 为中文名与值语义说明,随行必带;未下发的策略不返回,`node_spread_scope` 仅文件夹)。`disabledValues` 为不可设置取值列表(恒返回,无被禁档位时为空数组),每项含 `value`(被禁档位取值,与 value 同一值域)与 `reason`(服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null),与 allowedValues 互斥;示例:`{"value": "READER_AND_ABOVE", "reason": "企业安全策略要求不可低于可下载角色"}`。`value` 按策略分型:开关型为 ENABLED/DISABLED;member_invite、comment 为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE;node_spread、online_content_copy 为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE 或 NOBODY;node_spread_scope 为 ALL_NODES(限制对所有文档生效)/ PREVIEWABLE_ONLY(仅对可预览的文档生效)。NOBODY=该操作对所有人禁止;XXX_AND_ABOVE=不低于该角色才允许。name/description 示例(文案与产品权限设置页一致):external_share「添加企业外协作者」:是否允许添加企业外的人为协作者(ENABLED=允许,DISABLED=禁止);node_spread「谁可以下载、创建副本、打印」:允许哪些角色及以上的用户下载、创建副本、打印;NOBODY=所有人禁止下载、创建副本、打印;node_move_forbidden「禁止移动」:是否禁止移动到其他知识库或团队共享文件夹(ENABLED=禁止移动,DISABLED=允许移动)。
发起权限申请先只读执行 `permission apply-info`。正式 `permission apply` 会通知审批人;调用前必须向用户逐项回显并确认资源、申请角色、审批人和理由。Agent 不得默认选择第一位审批人、最高/最低角色或代写申请理由;用户未明确同意完整申请内容时停在确认环节。
## 快捷方式节点
+6 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-misc
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile/家校应用/家校通讯录/家校群/高校通讯录。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile/家校任务/班级消息摘要/学校组织架构/班级列表/师生群/班级群/高校院系部门/师生员工管理 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -37,6 +37,11 @@ metadata:
| 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) |
| PAT 授权 / 行为权限 / scope 授权 / 一次性授权 / 会话授权 / 永久授权 / 授权浏览器策略 | PAT 行为授权与本地浏览器策略 | `dws pat` | [pat.md](references/pat.md) |
| 切换组织 / 换组织 / 跨组织 / 多组织 / profile / 看登录了哪些组织 | 多组织 / profile 管理与跨组织取数 | `dws profile` / `dws auth` / `--profile` | [profile.md](references/profile.md) |
| 家校应用 / 班级消息摘要 / 家校任务(教育场景) | 家校应用(教育版) | `dws edu-app` | [edu-app.md](references/edu-app.md) |
| 家校通讯录 / 学校组织架构 / 班级列表(教育场景) | 家校通讯录(教育版) | `dws edu-contact` | [edu-contact.md](references/edu-contact.md) |
| 家校群 / 师生群 / 班级群(教育场景) | 家校群(师生群)查询、创建、解散 | `dws edu-group` | [edu-group.md](references/edu-group.md) |
| 家庭群 / 家长邀请 / 孩子管理 / 学生应用权限(教育场景) | 家庭群查询/创建、孩子管理、家长邀请 | `dws edu-familygroup` | [edu-familygroup.md](references/edu-familygroup.md) |
| 高校通讯录 / 高校组织架构 / 院系部门 / 师生员工管理(高校场景) | 高校通讯录:部门与师生员工管理、搜索、统计、升级 | `dws college-contact` | [college-contact.md](references/college-contact.md) |
| 宜搭 / AI应用脚本 / 财务辅助脚本(未产品化) | **无**稳定命令面;仅仓库内辅助脚本 | (非默认路由) | [unsupported-scripts.md](references/unsupported-scripts.md) |
## 说明
@@ -0,0 +1,135 @@
# 高校通讯录 (college-contact) 命令参考
## 命令总览
### dept (部门管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 |
| `dept get-detail` | 查询部门详情 | `--dept-id` |
| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` |
| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` |
| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` |
| `dept update` | 更新部门 | `--dept-id`, `--dept-type` |
| `dept delete` | 删除部门 ⚠️ | `--dept-id` |
| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` |
| `dept overview` | 查询高校概览统计 | 无 |
### employee (员工管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `employee get-detail` | 查询员工详情 | `--staff-id` |
| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` |
| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) |
| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` |
| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` |
| `employee send-active-sms` | 发送激活短信 | `--dept-id` |
| `employee list-employees` | 查询部门员工列表 | `--dept-id` |
| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` |
| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` |
| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 |
| `employee start-upgrade` | 启动高校通讯录升级 | 无 |
### alumni (校友管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` |
| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` |
| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` |
| `alumni query` | 查询单个校友详情 | `--staff-id` |
| `alumni search` | 搜索校友 | `--keyword` |
| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` |
| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` |
| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` |
| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` |
| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` |
| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) |
| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) |
| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) |
| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` |
| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) |
| `alumni create-group` | 创建校友群 | `--alumni-dept-id` |
| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` |
| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 |
| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` |
| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) |
### graduate (毕业年级管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 |
| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) |
| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` |
| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) |
| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) |
| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 |
| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` |
| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) |
| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) |
| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) |
| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) |
| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` |
| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) |
### group (规则管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) |
| `group get-group-rule-schedule` | 查询规则调度 | 无参数 |
| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) |
| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) |
| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` |
| `group enable-group-rule` | 启用规则 | `--rule-id` |
| `group disable-group-rule` | 停用规则 | `--rule-id` |
| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) |
| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 |
## 常用参数说明
- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工)
- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业)
- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表
- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序
- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false)
## 意图判断
用户说"高校架构/组织架构/学院/系/部门" → dept 子命令
用户说"搜人/找某某老师/找某某同学" → `dept search`
用户说"师生/教职工/学生/员工/辅导员" → employee 子命令
用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms`
用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade`
用户说"校友/校友会/校友部门/添加校友" → alumni 子命令
用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令
用户说"群规则/建群规则/自动建群" → group 子命令
## 核心工作流
1. 查标准架构 → `dept get-standard-structure`(提取 deptId)
2. 查看部门详情 → `dept get-detail --dept-id <deptId>`
3. 查看部门员工 → `employee list-employees --dept-id <deptId>`
4. 查看员工详情 → `employee get-detail --staff-id <staffId>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id |
| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids |
| `dept create` | `deptId` | update/delete 的 --dept-id |
| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id |
| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id |
| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id |
| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id |
## 危险操作
- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。
@@ -0,0 +1,198 @@
# 家校应用 (edu-app) 命令参考
## 命令总览
### message (消息管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` |
> `--target-role`: guardian(家长) / student(学生)
> `--status`: 0(未处理) / 1(已处理)
### task (任务管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) |
| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) |
| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) |
> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA
### report (成绩单管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) |
| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) |
| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` |
| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` |
| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` |
### notice (通知管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `notice create` | 创建并发布通知 | `--identifer`, `--content` |
| `notice get` | 查询通知详情 | `--notice-id` |
| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) |
| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` |
| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` |
| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` |
| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` |
> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier`
> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收
### circle (班级圈)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` |
> `--target-role`: guardian(家长视角) / student(学生视角)
> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。
### card (打卡管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `card update` | 修改打卡标题或内容(仅创建者) | `--card-id`, `--identifier`, `--title`/`--content` |
| `card end` | 提前结束打卡任务(仅创建者) | `--card-id` |
| `card list` | 查询打卡列表(学生/家长) | `--status`(FINISH/UNFINISH) |
| `card user-statistic` | 查询班级打卡完成/未完成人员(老师/班主任) | `--card-id`, `--task-code`, `--class-id` |
| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` |
> `card list --status`: FINISH(已完结) / UNFINISH(进行中)
> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断
> `card finish-info --student-id`: 当 targetRole 为 guardian 时,用于指定查看某个孩子的进度
### homework (作业管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` |
| `homework get` | 查询作业详情 | `--homework-id` |
| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) |
| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` |
| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` |
| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` |
| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` |
| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` |
| `homework submit` | 提交作业 | `--hw-content-detail-id` |
| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` |
| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` |
> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题
> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id`
### diploma (奖状管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` |
| `diploma get` | 查询奖状详情 | `--diploma-id` |
| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) |
| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` |
| `diploma detail` | 查询奖状接收详情 | `--diploma-id` |
| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` |
| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` |
| `diploma read` | 标记奖状为已读 | `--diploma-id` |
| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` |
> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板
## 危险操作
以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3):
| 命令 | 后果 |
|------|------|
| `notice delete --notice-id <id> --yes` | 删除通知,家长/学生侧不可恢复 |
| `homework delete --homework-id <id> --yes` | 删除作业及其提交记录 |
| `diploma delete --diploma-id <id> --yes` | 删除已颁发的奖状 |
其余命令均为读或普通写操作,不需要 `--yes`。
## 意图判断
用户说"消息/消息摘要" → message summary-list
用户说"家校任务/待办任务" → task 子命令
用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`)
用户说"成绩/成绩单" → report 子命令
用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`)
用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`)
用户说"班级圈/成长记录/学生动态" → circle posts
用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令
关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源)
关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结)
关键区分: diploma(奖状/表彰) vs report(成绩单)
老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。
## 核心工作流
### 老师场景
1. 查看发布的任务 → `task publish-list --need-statistic -f json`
2. 查看某班全部任务 → `task all-list --biz-id <classId>`
3. 查看成绩单 → `report by-teacher --status 1`
4. 查看班级成绩明细 → `report by-class --report-id <id> --class-id <classId>`
5. 查看某班打卡完成情况 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId> --finish`
6. 发布作业 → `homework create --identifier <orgId-staffId-UUID> --hw-content "第三章习题" --class-ids <classId>`
7. 查看作业提交统计 → `homework submit-statistics --homework-id <id> --class-id <classId>`
8. 批改作业写评语 → `homework create-comment --hw-content-detail-id <id> --comment "写得很好"`
9. 删除作业 → `homework delete --homework-id <id> --yes`
10. 发布通知 → `notice create --identifer <orgId-staffId-UUID> --content "明天放假" --class-ids <classId> --is-signed true`
11. 查看通知签收情况 → `notice confirm-status --notice-id <id> --class-id <classId>`
12. 删除通知 → `notice delete --notice-id <id> --yes`
13. 颁发奖状 → `diploma create --identifier <orgId-staffId-UUID> --content "三好学生" --user-name <老师姓名> --class-ids <classId>`
14. 查看奖状阅读统计 → `diploma statistics --diploma-id <id>`
15. 删除奖状 → `diploma delete --diploma-id <id> --yes`
### 家长场景
1. 查看孩子待办 → `task student-list --students '[{"userId":"<uid>","bizId":"<classId>"}]'`
2. 确认通知 → `notice confirm --notice-id <id> --student-id <uid>`
3. 查看孩子收到的通知 → `notice list-by-student --student-id <uid> --class-id <classId>`
4. 查看孩子班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role guardian`
5. 查看孩子进行中打卡 → `card list --status UNFINISH`
6. 查看孩子作业列表 → `homework list-by-student --student-id <uid> --class-id <classId> --user-name <家长姓名>`
7. 查看孩子收到的奖状 → `diploma list-by-student --student-id <uid> --class-id <classId>`
### 学生场景
1. 查看自己的班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role student`
2. 提交作业 → `homework submit --hw-content-detail-id <id> --content "已完成"`
3. 标记奖状已读 → `diploma read --diploma-id <id>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id |
| `dws edu-contact class students` | `userId` | task student-list 的 students.userId |
| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid |
| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id |
| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id |
| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id |
| `task publish-list` | `taskCode` | card user-statistic 的 --task-code |
| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id |
| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id |
| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id |
| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id |
---
## SKILL 摘要(原 dingtalk-edu-app/SKILL.md 正文)
## 意图表
| 用户说 | 命令 |
|--------|------|
| "查班级消息摘要" | `dws edu-app message summary-list --class-id <id> --cid <id> --target-role guardian\|student --status 0\|1` |
| "查家校任务" | 见 [edu-app.md](./edu-app.md) `task` 章节 |
## 跨产品协作
- 师生群本身 → 见本包 references/edu-group.md
- 家校通讯录 → 见本包 references/edu-contact.md
@@ -0,0 +1,92 @@
# 家校通讯录 (edu-contact) 命令参考
## 命令总览
### school (学校/组织管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `school roles` | 查询用户在组织内的身份 | 无 |
| `school structure` | 查询学校组织架构 | 无 |
| `school periods` | 查询学校学段信息 | 无 |
| `school type` | 查询学校组织类型 | 无 |
| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) |
| `school class-list` | 查询学校所有班级列表 | 无 |
### class (班级管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `class detail` | 查询班级详情 | `--dept-id` |
| `class students` | 查询班级学生信息 | `--dept-id` |
| `class teachers` | 查询班级老师列表 | `--dept-id` |
| `class same-name` | 查询班级内同名学生 | `--dept-id` |
| `class user-role` | 查询用户在班级内的角色 | `--dept-id` |
| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` |
| `class headmaster` | 根据班级名查询班主任 | `--class-name` |
| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` |
| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` |
| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` |
| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` |
| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` |
| `class update-info` | 更新班级信息 | `--class-id` |
| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` |
| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` |
| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` |
| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` |
| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` |
| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` |
### family (家庭关系查询)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `family children` | 查询家长的孩子信息 | 无 |
| `family parents` | 查询学生的家长信息 | 无 |
### teacher (教师管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `teacher classes` | 查询老师管理的班级列表 | 无 |
| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) |
## 意图判断
用户说"学校/组织/学段/组织架构" → school 子命令
用户说"班级/学生/教师/班主任" → class 子命令
用户说"家长/孩子/家庭关系" → family 子命令
用户说"任教/科目" → teacher update-course
## 核心工作流
1. 查询角色 → `school roles`
2. 查看班级列表 → `school class-list`(提取 deptId)
3. 查看班级详情 → `class detail --dept-id <deptId>`
4. 查看学生列表 → `class students --dept-id <deptId>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `school class-list` | `deptId` | class 子命令的 --dept-id |
| `class students` | `userId` | update-student/delete-students 的 --student-user-id |
| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id |
---
## SKILL 摘要(原 dingtalk-edu-contact/SKILL.md 正文)
## 意图表
| 用户说 | 命令 |
|--------|------|
| "我在学校的身份" | `dws edu-contact school roles` |
| "学校组织架构" | `dws edu-contact school structure` |
| "学校学段 / 类型" | `dws edu-contact school periods` / `school type` |
| "学校所有班级" | `dws edu-contact school class-list` |
| "学校统计" | `dws edu-contact school stats [--statistics-type <t>]` |
## 跨产品协作
- 企业通讯录场景 → 切到 `dingtalk-contact`
@@ -0,0 +1,68 @@
# 家庭群 (edu-familygroup) 命令参考
## 命令总览
### group (家庭群查询)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` |
| `group list-children` | 查询家长绑定的孩子列表 | `--uid` |
### manage (家庭群管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `manage create` | 创建家庭群 | `--uid`, `--children` |
| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` |
| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) |
| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` |
## 意图判断
用户说"家庭群存在/有没有家庭群" → group check-exists
用户说"孩子列表/我的孩子" → group list-children
用户说"创建家庭/建群" → manage create
用户说"邀请家长/拉家长入群" → manage invite-parent
用户说"添加孩子/加娃" → manage add-child
用户说"应用权限/小天地/学习视频" → manage toggle-app
## 核心工作流
1. 检查家庭群是否存在 → `group check-exists --uid <uid> --group-name <name>`
2. 如不存在,创建家庭群 → `manage create --uid <uid> --children '<json>'`
3. 查看已绑定孩子 → `group list-children --uid <uid>`
4. 邀请其他家长 → `manage invite-parent --org-id <orgId> --uid <uid> --mobile <phone>`
5. 添加孩子 → `manage add-child --org-id <orgId> --uid <uid> --name <name> --mobile <phone>`
6. 管理应用权限 → `manage toggle-app --org-id <orgId> --uid <uid> --child-staff-id <id> --app-type XIAOTIANDI --open true`
## 参数说明
### manage create --children 格式
```json
[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]
```
每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。
### manage add-child --students 格式
```json
[{"schoolOrgId":111,"studentStaffId":"stu001"}]
```
每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。
### manage toggle-app --app-type 可选值
- `XIAOTIANDI`:小天地(学生圈)
- `LEARNING_VIDEO`:学习视频
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id |
| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id |
| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid |
@@ -0,0 +1,77 @@
# 家校群 (edu-group) 命令参考
## 命令总览
### student-group (师生群管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `student-group info` | 查询班级师生群信息 | `--dept-id` |
| `student-group exists` | 检查是否已创建师生群 | `--dept-id` |
| `student-group members` | 查询师生群成员列表 | `--dept-id` |
| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` |
| `student-group conversation` | 查询班级群会话详情 | `--dept-id` |
| `student-group create` | 创建班级师生群 | `--dept-id` |
| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` |
### class-group (班级群会话管理)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` |
| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` |
| `class-group exists` | 检查班级群是否存在 | `--dept-id` |
| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` |
### batch (批量操作)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` |
| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` |
| `batch create-student-groups` | 批量创建师生群 | 无 |
## 意图判断
用户说"师生群/学生群" → student-group 子命令
用户说"班级群/群会话" → class-group 子命令
用户说"批量/一键操作" → batch 子命令
关键区分: student-group(师生群) vs class-group(班级群会话管理)
## 核心工作流
1. 检查群是否存在 → `student-group exists --dept-id <deptId>`
2. 如不存在,创建 → `student-group create --dept-id <deptId>`
3. 查看成员 → `student-group members --dept-id <deptId>`
4. 获取会话ID → `class-group conversation-id --dept-id <deptId>`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id |
| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid |
| `batch check-student-group` | 未创建群的班级 | batch create-student-groups |
---
## SKILL 摘要(原 dingtalk-edu-group/SKILL.md 正文)
## 意图表
| 用户说 | 命令 |
|--------|------|
| "查师生群信息 / 是否已建" | `dws edu-group student-group info --dept-id <id>` / `exists --dept-id <id>` |
| "师生群成员" | `dws edu-group student-group members --dept-id <id>` |
| "建班级师生群" | `dws edu-group student-group create --dept-id <id>` |
| "解散班级师生群 ⚠️" | `dws edu-group student-group disband --dept-id <id>`(需用户确认 `--yes`) |
## 危险操作
`student-group disband` 不可逆,必须先向用户确认再加 `--yes`。
## 跨产品协作
- 班级列表 → 见本包 references/edu-contact.md(school class-list)
- 企业群 → 切到 `dingtalk-chat`
@@ -51,7 +51,7 @@
| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 |
| 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 |
| 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 |
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [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 当成当前最终值 |
@@ -80,7 +80,7 @@
| [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` |
| [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` |
| [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` |
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
| [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` |
@@ -237,7 +237,7 @@ Flags:
| "搜索公式文本" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<公式片段>" --match-formula` |
| "正则搜索 / 不区分大小写" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<regexp>" --use-regexp --match-case=false` |
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1 --file <图片路径>` |
| "创建浮动图片" | 先 `dws sheet media-upload --node <nodeId或URL> --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --src "<resourceUrl>" --range A1 --width <宽> --height <高>` |
| "创建浮动图片" | `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --file <图片路径> --range A1 --width <宽> --height <高>` |
## URL 与 ID 前置
@@ -17,7 +17,7 @@
### 浮动图片
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src`
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
@@ -71,7 +71,11 @@ Flags:
Usage:
dws sheet create-float-image [flags]
Example:
# 先上传图片获取 resourceUrl
# 直接上传本地图片并创建浮动图片
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--file ./chart.png --range A1 --width 400 --height 300
# 高级用法:先上传图片获取 resourceUrl
dws sheet media-upload --node <NODE_ID> --file ./chart.png
# 输出: resourceUrl: /core/api/resources/img/xxxx...
@@ -85,7 +89,8 @@ Example:
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
--file string 本地图片文件路径,与 --src 二选一
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
--width int 图片宽度,像素,正整数 (必填)
--height int 图片高度,像素,正整数 (必填)
@@ -94,7 +99,8 @@ Flags:
```
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `--width` / `--height` 为必填,单位像素,必须为正整数
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
@@ -138,13 +144,18 @@ Example:
# 调整尺寸
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
# 直接用本地图片替换
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
--file ./replacement.png
# 高级用法:通过已上传的 resourceUrl 替换
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
--src "/core/api/resources/img/xxxx..."
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
--file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
--range string 新的锚点单元格,A1 表示法
--width int 新的图片宽度,像素
@@ -153,7 +164,7 @@ Flags:
--offset-y int 新的垂直偏移量,像素
```
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。
`--float-image-id` 可通过 `list-float-images` 获取。
### 删除浮动图片
@@ -210,6 +221,16 @@ dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B1" \
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A2" \
--values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
# ── 工作流 11: 创建或替换浮动图片 ──
# 从本地图片直接创建
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--file ./chart.png --range A1 --width 400 --height 300 -f json
# 从本地图片直接替换已有浮动图
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--float-image-id <FI_ID> --file ./replacement.png -f json
```
## 上下文传递
@@ -232,11 +253,11 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
- `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
- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src`
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file`
- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
+76
View File
@@ -351,6 +351,79 @@ This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable.
}
}
func TestReleaseFragmentPolicyAcceptsOnlyUntaggedCanonicalBetaAmendments(t *testing.T) {
newAmendmentRepo := func(t *testing.T) (*changelogGateRepo, string) {
t.Helper()
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
t.Fatalf("release fragment policy rejected initial beta seal: %v\noutput:\n%s", err, output)
}
changelogGateWrite(t, repo.root, ".changes/1235-sheet.md", "---\ncategory: Added\n---\n\n- Sheet accepts local float image files.\n", 0o644)
repo.commit(t, "merge post-seal beta fragment")
return repo, strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
}
stageCanonicalAmendment := func(t *testing.T, repo *changelogGateRepo) {
t.Helper()
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
## [Unreleased]
## [1.0.1-beta.1] - 2026-07-17
### Added
- Chat reply mentions.
- Sheet accepts local float image files.
## [1.0.0] - 2026-07-01
### Added
- Initial release.
`, 0o644)
archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1")
if err := os.Rename(filepath.Join(repo.root, ".changes", "1235-sheet.md"), filepath.Join(archiveDir, "1235-sheet.md")); err != nil {
t.Fatalf("Rename beta amendment fragment: %v", err)
}
repo.commit(t, "amend untagged beta release notes")
}
t.Run("accepts exact pre-tag merge", func(t *testing.T) {
repo, amendmentBase := newAmendmentRepo(t)
stageCanonicalAmendment(t, repo)
if output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD"); err != nil {
t.Fatalf("release fragment policy rejected canonical beta amendment: %v\noutput:\n%s", err, output)
}
})
t.Run("rejects tagged beta", func(t *testing.T) {
repo, amendmentBase := newAmendmentRepo(t)
stageCanonicalAmendment(t, repo)
changelogGateGit(t, repo.root, "tag", "v1.0.1-beta.1")
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
if err == nil || !strings.Contains(output, "forbidden after tag v1.0.1-beta.1 exists") {
t.Fatalf("tagged beta amendment passed: err=%v\noutput:\n%s", err, output)
}
})
t.Run("rejects rewritten sealed prose", func(t *testing.T) {
repo, amendmentBase := newAmendmentRepo(t)
stageCanonicalAmendment(t, repo)
changelogGateWrite(t, repo.root, "CHANGELOG.md", strings.Replace(changelogGateSealedRelease, "Chat reply mentions.", "Rewritten sealed note.", 1), 0o644)
repo.commit(t, "rewrite sealed beta prose")
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
if err == nil || !strings.Contains(output, "does not exactly match") {
t.Fatalf("rewritten beta amendment passed: err=%v\noutput:\n%s", err, output)
}
})
}
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
t.Run("invalid active fragment", func(t *testing.T) {
repo := newChangelogGateRepo(t)
@@ -1106,6 +1179,9 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
t.Fatal("Code Admission workflow missing Policy job boundaries")
}
policyJob := admission[policyStart:policyEnd]
if !strings.Contains(policyJob, "timeout-minutes: 15") {
t.Error("Policy job must retain enough headroom for full Schema policy validation")
}
requirePolicyEnv := func(step, nextStep string) {
t.Helper()
start := strings.Index(policyJob, " - name: "+step+"\n")