Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50d7e7d98f | ||
|
|
000bc13450 | ||
|
|
ba56b7ff78 | ||
|
|
c5c97e60f3 | ||
|
|
6780177356 | ||
|
|
5fa40b8ada | ||
|
|
8a7d4a7027 | ||
|
|
894e550950 | ||
|
|
bb205c0f5c | ||
|
|
82c6b631bf | ||
|
|
306c30ecad | ||
|
|
f793d980b5 | ||
|
|
706dbb349c | ||
|
|
45d0d1cd72 | ||
|
|
e9a2360d36 | ||
|
|
9531dad9c6 | ||
|
|
20d27f6db9 | ||
|
|
ca2b8adcb2 | ||
|
|
a3c7009f9b | ||
|
|
2d3f820f91 | ||
|
|
f5a1b64d7a | ||
|
|
c1f96241af | ||
|
|
eb63b3933e | ||
|
|
eb571e6e73 | ||
|
|
4d5a47ac93 | ||
|
|
6108f51c9d | ||
|
|
31e3f6bcbc | ||
|
|
678f108adf | ||
|
|
a240ad2b81 | ||
|
|
f39a3f5417 | ||
|
|
e806e761ad | ||
|
|
a6696fe9e9 | ||
|
|
06af21c7a1 | ||
|
|
eba2b692ec |
@@ -702,14 +702,11 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Full ./internal/app (including schema --all assembly) already runs in the
|
||||
# race:app shard. Keep this job focused on native auth/keychain paths so
|
||||
# heavy Schema completeness tests cannot exhaust the 10m budget.
|
||||
- name: Test macOS auth and Keychain paths with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
- name: Test macOS auth and Keychain packages with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth
|
||||
|
||||
- name: Test macOS auth migration and portable auth diagnostics
|
||||
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
|
||||
- name: Test macOS auth migration, Keychain diagnostics, and upgrade self-heal with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))'
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
|
||||
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
|
||||
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
|
||||
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
|
||||
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
|
||||
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
|
||||
- [`dws report` — Reports](#dws-report) · 7 commands
|
||||
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
|
||||
|
||||
@@ -277,14 +277,17 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
|
||||
|
||||
## `dws oa` — OA Approval
|
||||
|
||||
_OA approval workflows: list, approve, reject, revoke, records._
|
||||
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
|
||||
|
||||
**9 commands**
|
||||
**12 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
|
||||
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
|
||||
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
|
||||
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
|
||||
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
|
||||
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
|
||||
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
|
||||
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# 声明式命令框架迁移指南
|
||||
|
||||
- **受众**:把 helpers 叶子 / Shortcut 从 Tier3 / 遗留 annotate 迁到声明式框架的工程师
|
||||
- **规范权威**(本文不重复政策全文,只给可执行路径):
|
||||
- [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(声明定义、三档路径、Schema 字段权威)
|
||||
- [`flag-help-schema-homology.md`](flag-help-schema-homology.md)(路径 A:Contract 嵌入 Schema)
|
||||
- 仓库根 [`AGENTS.md`](../AGENTS.md)(Authoring tiers、Agent Schema contract、curation)
|
||||
- **架构速览**:[`command-framework-architecture.md`](command-framework-architecture.md)
|
||||
- **实现基线**:PR #830 起 `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` → `corecmd.New`;Schema 经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 运行时组装(**声明即 Catalog**)
|
||||
|
||||
---
|
||||
|
||||
## 1. Why / 终态
|
||||
|
||||
目标不是「再写一份 Catalog JSON」,而是 **一份声明同时驱动**:
|
||||
|
||||
1. Cobra 可执行表面(flags / required / constraints / help)
|
||||
2. 运行时确认门(`confirmation=user_required` → `ConfirmSafety`)
|
||||
3. Agent Schema(`ToolSpec` / `ResolveMeta`)
|
||||
|
||||
**单向数据流(摘要)**:
|
||||
|
||||
```text
|
||||
leaf Safety + Contract / ParamDecl / ProductDecl
|
||||
→ ContractFinal(corecmd.New 或 AttachContract / DeclareLeafMetadata)
|
||||
→ CollectIdentitySpecs(活 Cobra 叶上的 Identity)
|
||||
→ ResolveSchemaBuild → SchemaRegistry
|
||||
→ dws schema / ResolveMeta(同一组装结果)
|
||||
```
|
||||
|
||||
硬约束:
|
||||
|
||||
| 要做 | 不要做 |
|
||||
|---|---|
|
||||
| 在叶子旁声明 `Safety` / `Contract` / `ParamDecl` | 提交 `schema_catalog/`、`schema_meta_index.*`、`schema_agent_metadata/`、`schema_hints/`、`schema_mcp_metadata.json` |
|
||||
| 用 `make generate-schema` 刷新 param aliases + 证明组装确定性 | 把 Catalog dump 当交付权威或手工改 wire |
|
||||
| help/Schema 事实 **declare OR annotate** | 纯推断、或钩子闭包里「顺带」发明表面 |
|
||||
| `Confirmation` 单独驱动运行时门 | 从 `effect`/`risk` 机械推导 `confirmation` |
|
||||
|
||||
`cmd_schema_catalog` 仅供 CI/local dump;生产路径没有 committed Catalog pin。
|
||||
|
||||
---
|
||||
|
||||
## 2. Authoring tiers(何时用哪一档)
|
||||
|
||||
同一 `ContractFinal` 语义;三档不是互相否定。详见 RFC §5.0.2a。
|
||||
|
||||
| 档 | 入口 | 声明什么 | 执行面 | 何时用 |
|
||||
|---|---|---|---|---|
|
||||
| **Tier1** | `corecmd.New` / `helpers.NewLeafCommand(LeafSpec)` | `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` 全进 Spec | 框架注册 flag、投影参数、`ConfirmSafety`、派发 | **新命令默认**;执行面可迁入 LeafSpec/`Call` |
|
||||
| **Tier2** | `helpers.DeclareLeafMetadata(cmd, LeafSpec)` | 仅 `Safety` + `Contract`(可选 `Validate`) | **不**注册 flag;确认挂 RunE 包装器 | helpers / Shortcut 迁移态:先补 Agent Schema,执行面暂时手写 `RunE`/`Execute` |
|
||||
| **Tier3** | 裸 `*cobra.Command` | 无框架声明(或仅 annotate / 精确排除) | 调用方自管 | 应收窄;新增裸叶须迁元数据或进 `schema_command_exclusions.go` |
|
||||
|
||||
选用规则(今日):
|
||||
|
||||
1. 新命令 → **Tier1**。
|
||||
2. 既有叶子要进 Schema、但 `RunE`/`Execute` 还不想重写 → **Tier2**(声明写在命令字面量旁)。
|
||||
3. **Shortcut + `DeclareLeafMetadata` 合法**;多数 Shortcut 已走 `FromShortcut` → `corecmd.New`(Tier1 表面 + 自有 `Execute`)。
|
||||
4. Tier2 → Tier1 与 Shortcut → mcpbind 是后续里程碑,**不是**本阶段硬门槛;不得用 Tier2 绕开「业务 flag 必须声明」的纪律(半接管字段会 panic)。
|
||||
|
||||
`DeclareLeafMetadata` 禁止传入:`Flags` / `Constraints` / `ConstParams` / `Call` / `RunE` / `PostMount` / `ConfirmFirst` / `Server`/`Tool`;**唯一允许的执行钩子是 `Validate`**。
|
||||
|
||||
---
|
||||
|
||||
## 3. What to declare(声明清单)
|
||||
|
||||
### 3.1 框架声明面(数据字段才算声明)
|
||||
|
||||
| 字段 | 作用 | Schema / 运行时 |
|
||||
|---|---|---|
|
||||
| `Flags`(Tier1) | kebab-case flag、Kind、Default、Required、`Bind`、Aliases… | parameters;`Bind` → property |
|
||||
| `Constraints`(Tier1) | `at_least_one` / `exactly_one` / `mutually_exclusive` / custom | constraints 段 + 运行时校验 |
|
||||
| `Safety`(`contract.SafetySpec`) | `effect` / `risk` / `confirmation` / `idempotency` **四字段齐全** | ToolSpec Safety;仅 `confirmation` 驱动确认门 |
|
||||
| `ConstParams`(Tier1) | 固定 toolArgs,不上用户 flag 表 | 载荷;非 parameters |
|
||||
| `Contract`(`corecmd.ContractDecl`) | Identity / Description / Selection / Parameters / Interface / DryRun | ContractFinal → Catalog |
|
||||
| `ProductDecl` | 产品级 routing(`contract.RegisterProductDecl`) | 产品 overview selection |
|
||||
|
||||
钩子(`Validate` / `Call` / `Invoke` / `Orchestrate` / `RunE` / `Execute` / `PostMount`)**不算**声明——行为正确也不能替代 Schema 事实。
|
||||
|
||||
### 3.2 Contract 必填要点
|
||||
|
||||
- `Identity`:`ProductID` / `Name` / `CanonicalPath` / `CLIPath` / `PrimaryCLIPath`(与活 Cobra 路径一致;collector 以此为唯一 identity 源)
|
||||
- `Description`:构造期必填(声明证据);Catalog **交付**可优先 Cobra Long(provenance `cobra_help`)
|
||||
- `Selection`:决策向 `AgentSummary` / `UseWhen` / `AvoidWhen` / `Examples`(勿复述 Short;**禁止** `Reviewed` 字段)
|
||||
- `Parameters`(`[]contract.ParamDecl`):CLI flag 名 → RPC `Property`;`interface_type` 等接口事实写这里,不写 MCP pin
|
||||
- `Interface`:`mcp` / `composite` / `local` + `Ref` 或 `Reason`
|
||||
|
||||
### 3.3 ProductDecl
|
||||
|
||||
在产品根命令构造处注册一次,例如 `internal/helpers/drive.go` 的 `newDriveCommand`:
|
||||
|
||||
```go
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "drive",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "…",
|
||||
UseWhen: []string{"…"},
|
||||
AvoidWhen: []string{"…"},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 分步迁移清单
|
||||
|
||||
### 4.1 典型 helpers 叶子(裸 Cobra → Tier2)
|
||||
|
||||
1. **确认 CLI 路径**:`dws <path> --help`,记下 flag 名与 required 组。
|
||||
2. **选定 Identity**:`product_id` / `name` / `canonical_path` / `cli_path` 与兄弟命令不冲突;aliases 若有则写进 Identity(与 collected registry 一致)。
|
||||
3. **声明 Safety**:写操作填完整四字段;删除/破坏性用 `confirmation=user_required`(见 §5)。
|
||||
4. **声明 Contract**:`Description` + `Selection` + `Interface`;flag→RPC 用 `Parameters: []ParamDecl{{Name, Property}}`。
|
||||
5. **挂元数据**:在 flag 注册与 `RunE` 之后调用:
|
||||
|
||||
```go
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{ /* 四字段 */ },
|
||||
Contract: LeafContract{ /* Identity / Selection / … */ },
|
||||
// 本地副作用且 user_required:补 Validate,避免确认抢先或 fail-closed
|
||||
})
|
||||
```
|
||||
|
||||
6. **Property 缺口**:CLI 有、RPC 无的 selector → `schema_parameter_mapping_ledger.go` 的 `mapping_exclusions`(精确键 + 非空 reason),不要造 binding JSON。
|
||||
7. **产品路由**:若产品尚无 `ProductDecl`,在产品根补注册。
|
||||
8. **验证**(§7)后再开 PR。
|
||||
|
||||
参考:`internal/helpers/drive.go`(`DeclareLeafMetadata` + `RegisterProductDecl`)。
|
||||
|
||||
### 4.2 典型 Shortcut(补 Safety + Contract)
|
||||
|
||||
1. Shortcut 已由 `FromShortcut` → `corecmd.New` 接管 flag/约束/确认;迁移焦点是 **显式 `Safety` + `Contract`**,不要再依赖仅 `Risk` 推断。
|
||||
2. 在 `shortcut.Shortcut{…}` 字面量上增加:
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{ /* … */ },
|
||||
Description: "…",
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "…"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "…",
|
||||
UseWhen: []string{"…"},
|
||||
AvoidWhen: []string{"…"},
|
||||
Examples: []string{"dws chat +conversation-info --group <openConversationId>"},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
3. `Flags` / `Constraints` / `Validate` / `Execute` 保持 Shortcut 词汇;adapter 映射到 `corecmd.Spec`。
|
||||
4. Examples 用真实可执行路径与 flag;**不要**写 `--yes`。
|
||||
5. 验证(§7)。
|
||||
|
||||
参考:`internal/shortcut/chat/chat_conversation.go`(`ConversationInfo`);adapter:`internal/shortcut/adapter.go`。
|
||||
|
||||
### 4.3 新命令 / 可重写执行面 → Tier1
|
||||
|
||||
优先 `NewLeafCommand(LeafSpec{ Flags, Safety, Contract, Call/… })`,例如 `internal/helpers/devapp.go`。业务 flag 只进 `Flags`,禁止在 `PostMount`/`Validate` 里 `Flags().String` 注册业务面。
|
||||
|
||||
---
|
||||
|
||||
## 5. Safety / confirmation
|
||||
|
||||
### 5.1 声明完整 SafetySpec
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", // read | write | destructive
|
||||
Risk: "high", // low | medium | high
|
||||
Confirmation: "user_required", // not_required | user_required
|
||||
Idempotency: "unknown", // idempotent | retryable | non_idempotent | unknown
|
||||
},
|
||||
```
|
||||
|
||||
非空则四字段必须齐全(构造期校验)。字段独立:`effect=destructive` **不会**自动变成 `user_required`。
|
||||
|
||||
### 5.2 迁离 AnnotateRuntimeRisk / AnnotateRuntimeGate
|
||||
|
||||
| 旧路径 | 新路径 |
|
||||
|---|---|
|
||||
| `AnnotateRuntimeRisk` / 字符串 `dws.schema.risk` | 声明 `Safety.Risk`(经 ContractFinal) |
|
||||
| `AnnotateRuntimeGate` / `runtime_gate`(如 `devAppRequireWriteGuard`) | 声明 `Safety.Confirmation=user_required`;框架 `ConfirmSafety` |
|
||||
| 仅 Shortcut `Risk=write` 隐式确认 | 显式 `Safety`(覆盖 Risk 展开) |
|
||||
|
||||
**禁止新增**生产 `AnnotateRuntimeRisk` / `AnnotateRuntimeGate` 调用点;存量 annotate 可保留至迁完(`HOM-S2`)。同源门禁:`confirmation=user_required` ↔ 运行时 gate(见 `check-runtime-confirmation-truth.sh`、`internal/cli/homology`)。
|
||||
|
||||
### 5.3 Tier2 确认时机
|
||||
|
||||
- 有 `Validate`:`Validate` → `ConfirmSafety` → 原 `RunE`
|
||||
- 无 `Validate`:确认推迟到首次 `CallTool`;无 Caller 的本地副作用叶必须补 `Validate`,否则 fail-closed
|
||||
|
||||
执行前需确认时,用 `--yes` 跳过交互;**Schema examples 永不包含 `--yes`**。
|
||||
|
||||
---
|
||||
|
||||
## 6. Parameters / mapping
|
||||
|
||||
1. **主权威**:Tier1 用 `FlagSpec.Bind`(空则 Name);Tier2 / 混合路径用 `contract.ParamDecl{Name, Property}`。
|
||||
2. **mapping ledger**(`internal/cli/schema_parameter_mapping_ledger.go`):只放 `mapping_exclusions` / removals——CLI flag **无**直接 RPC property 时的精确评审排除;非空 reason。
|
||||
3. **不要**:提交 MCP pin(`schema_mcp_metadata.json` 已退役);不要指望 live MCP 创建 CLI flag;不要用 hints overlay 改 `type`/`required`/`default`。
|
||||
4. **required 地板**:Cobra `MarkFlagRequired` 不得被低优先级源降为 optional。
|
||||
5. 接口事实(`interface_ref` / `interface_type`)声明在 leaf `Contract.Interface` / `ParamDecl`;CLI path ≠ MCP path 时用 `Interface.Ref`(例:`drive delete` → `doc.delete_document`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. Schema 验证
|
||||
|
||||
迁移后至少跑:
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
./scripts/policy/check-schema-catalog.sh
|
||||
./scripts/policy/check-runtime-confirmation-truth.sh # 若动了 confirmation / gate
|
||||
# 聚焦:
|
||||
go test ./internal/app -run 'TestSheetFinalSchemaConfirmationMatchesRuntimeGuards|TestFinalSchemaParametersMatchExecutableHelpFlags' -count=1
|
||||
```
|
||||
|
||||
手工抽查:
|
||||
|
||||
```bash
|
||||
dws schema --cli-path "drive mkdir" -f json
|
||||
# 或 ResolveMeta 同源:help Safety 行与 Schema confirmation 一致
|
||||
dws drive mkdir --help
|
||||
```
|
||||
|
||||
Examples 规则(组装/门禁):
|
||||
|
||||
- 每 tool 最多两条;路径与 flag 必须是活 Cobra 可接受的
|
||||
- **禁止** `--yes`;禁止 shell 注释
|
||||
- 缺必填 / 约束失败 = 契约 bug,不是「跳过 example」的理由
|
||||
|
||||
可选:`make test-schema-agent-examples`(合同 + 显式 dry-run 能力子集)。
|
||||
|
||||
---
|
||||
|
||||
## 8. Pitfalls(常见坑)
|
||||
|
||||
1. **Schema source root = declarationOnly**
|
||||
`app.NewSchemaSourceRootCommand` 以 `declarationOnly=true` 建树:**跳过** `helpers.InitDeps` / `injectStaticServers`,避免组装时清掉进程里的 ToolCaller / plugin endpoint。叶子构造与 RunE 包装必须对 `deps == nil` 安全(见 `printDocDeprecationWarning` 注释,`internal/helpers/doc.go`)。
|
||||
|
||||
2. **DryRun `RemoteReads`**
|
||||
声明 `Contract.DryRun` / `DryRunSpec` 时,`RemoteReads: false` 表示预览计划不发起远程读;勿把「有 dry-run 能力」与「会打后端」混为一谈。无 reviewed dry-run 能力时,example 门禁不会魔法升级为 runtime dry-run。
|
||||
|
||||
3. **不要复活退役目录 / pin**
|
||||
`schema_hints/`、`schema_agent_metadata/`、`schema_command_registry/`、committed `schema_catalog/`、`schema_mcp_metadata.json` —— 出现即 policy 失败。
|
||||
|
||||
4. **Tier2 半接管**
|
||||
往 `DeclareLeafMetadata` 塞 `Flags`/`Call`/`RunE` 会 panic;要框架管 flag → 升 Tier1。
|
||||
|
||||
5. **Identity 漂移**
|
||||
`Contract.Identity` 必须与活路径一致;不一致组装失败。排除项用 `schema_command_exclusions.go` 精确路径 + reason,禁止前缀通配。
|
||||
|
||||
6. **Selection 里塞 `Reviewed`**
|
||||
旧 hints 标记;声明载荷携带即组装报错。
|
||||
|
||||
7. **在钩子里发明 flag/property**
|
||||
Schema 看不到;同源门禁(`HOM-P*` / `HOM-D1`)会打回来。
|
||||
|
||||
---
|
||||
|
||||
## 9. Worked mini-example(前后对照)
|
||||
|
||||
### 9.1 helpers:裸叶 → Tier2(模式摘自 `drive mkdir` / `drive delete`)
|
||||
|
||||
**Before(概念)**:手写 `cobra.Command` + `RunE` + `Flags().String`,无 `Safety`/`Contract` → Agent Schema 缺叶子或靠已退役 hints。
|
||||
|
||||
**After(Tier2,缩写)**:
|
||||
|
||||
```go
|
||||
driveMkdirCmd := &cobra.Command{
|
||||
Use: "mkdir",
|
||||
Short: "创建文件夹",
|
||||
RunE: /* 既有 callMCPTool("create_folder", …) */,
|
||||
}
|
||||
driveMkdirCmd.Flags().String("name", "", "…")
|
||||
driveMkdirCmd.Flags().String("folder", "", "…")
|
||||
|
||||
DeclareLeafMetadata(driveMkdirCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive", Name: "create_folder",
|
||||
CanonicalPath: "drive.create_folder",
|
||||
CLIPath: "drive mkdir", PrimaryCLIPath: "drive mkdir",
|
||||
},
|
||||
Description: "创建文件夹",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp", Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "create_folder"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建文件夹",
|
||||
UseWhen: []string{"用户要在钉盘下新建普通文件夹时"},
|
||||
AvoidWhen: []string{"知识库内建文件夹改用 dws wiki node create …"},
|
||||
Examples: []string{`dws drive mkdir --name "项目资料" --format json`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "folder", Property: "parentId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
破坏性写示例(`drive delete`):`Confirmation: "user_required"`,`Effect: "destructive"`,`Interface.Ref` 可指向 `doc`/`delete_document`。
|
||||
|
||||
路径:`internal/helpers/drive.go`。
|
||||
|
||||
### 9.2 Tier1 LeafSpec(`dev app event list` 缩写)
|
||||
|
||||
```go
|
||||
return NewLeafCommand(LeafSpec{
|
||||
Use: "list", Short: "查询应用已订阅的事件列表",
|
||||
Tool: devAppEventListTool,
|
||||
Safety: /* read / low / not_required / idempotent */,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "unified-app-id", Bind: "unifiedAppId", Required: true, Trim: true},
|
||||
{Name: "keyword", Bind: "keyword", OmitEmpty: true, Trim: true},
|
||||
},
|
||||
Contract: LeafContract{ /* Identity + Selection + Interface */ },
|
||||
Call: devAppCallCursor(runner),
|
||||
})
|
||||
```
|
||||
|
||||
路径:`internal/helpers/devapp.go`。
|
||||
|
||||
### 9.3 Shortcut(`chat +conversation-info`)
|
||||
|
||||
见 §4.2;完整字面量:`internal/shortcut/chat/chat_conversation.go`。`FromShortcut` 把 `Flags`/`Constraints`/`Safety`/`Contract` 编进 `corecmd.Spec`(`internal/shortcut/adapter.go`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. Done criteria(完成标准)
|
||||
|
||||
迁移 PR 合并前,下列应满足:
|
||||
|
||||
| 检查项 | 证据 |
|
||||
|---|---|
|
||||
| 叶子有完整 `Safety`(或存量 `runtime_gate` 未扩大) | `dws schema --cli-path "…" -f json` 四字段与声明一致 |
|
||||
| `user_required` ↔ 运行时确认 | `./scripts/policy/check-runtime-confirmation-truth.sh`;相关 `HOM-S1`/`HOM-S2` |
|
||||
| Identity / 路径可执行且进 Schema(或精确 exclusion) | `CollectIdentitySpecs` / reverse-completeness;无前缀排除 |
|
||||
| parameters ⊆ help flags;property 有声明或 mapping exclusion | `check-schema-catalog.sh`;`HOM-P1`/`HOM-D1` |
|
||||
| 无退役 pin / hints / agent_metadata / committed catalog | policy 脚本;`git status` 无这些路径 |
|
||||
| `make generate-schema` 通过(aliases + 组装确定性) | CI / 本地 |
|
||||
| Examples 可执行、无 `--yes` | 组装 example 门禁;必要时 `make test-schema-agent-examples` |
|
||||
| gofmt;未无关改动 | PR diff |
|
||||
|
||||
长期可选(非本阶段硬门槛):Tier2 → Tier1;Shortcut `Execute` 中「只为装配参数」的函数体收敛到 mcpbind 形态 1/2(RFC §3.5 / §5.0.2a.5)。
|
||||
|
||||
---
|
||||
|
||||
## 附录:相关文件速查
|
||||
|
||||
| 主题 | 路径 |
|
||||
|---|---|
|
||||
| Leaf Tier1/Tier2 API | `internal/helpers/leaf.go` |
|
||||
| Leaf → corecmd | `internal/helpers` `FromLeafSpec`;`internal/corecmd` |
|
||||
| Shortcut → corecmd | `internal/shortcut/adapter.go` |
|
||||
| Safety / Contract DTO | `internal/corecmd/contract` |
|
||||
| ContractFinal 注册 | `internal/corecmd/contractfinal` |
|
||||
| Schema 组装入口 | `internal/cli`:`RegisterSchemaSourceRoot`、`ResolveSchemaBuild`、`ResolveMeta` |
|
||||
| 精确排除 | `internal/cli/schema_command_exclusions.go` |
|
||||
| mapping exclusions | `internal/cli/schema_parameter_mapping_ledger.go` |
|
||||
| declaration-only 根 | `internal/app.NewSchemaSourceRootCommand` |
|
||||
+13
-3
@@ -27,9 +27,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
// mountLegacyPublicCommands builds the product + shortcut command tree without
|
||||
// mutating process-global MCP deps or dynamic server endpoints. Used by the
|
||||
// Schema source root (declaration-only) path so assembly cannot clobber a live
|
||||
// runtime's InitDeps caller or plugin endpoints.
|
||||
func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) []*cobra.Command {
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
|
||||
// command tree, so distilled high-frequency operations mount alongside the
|
||||
@@ -51,6 +53,14 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller,
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
// newLegacyPublicCommands is the executable CLI path: inject static MCP
|
||||
// endpoints, InitDeps, then mount the public command tree.
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
return mountLegacyPublicCommands(runner, loadUserShortcuts)
|
||||
}
|
||||
|
||||
func injectStaticServers() {
|
||||
hooks := edition.Get()
|
||||
var servers []edition.ServerInfo
|
||||
|
||||
+14
-5
@@ -374,19 +374,21 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
return newRootCommandWithEngine(rootCtx, nil, true)
|
||||
return newRootCommandWithEngine(rootCtx, nil, true, false)
|
||||
}
|
||||
|
||||
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
|
||||
// used as the Schema assembly source root (RegisterSchemaSourceRoot →
|
||||
// ResolveSchemaBuild) and by command-surface policy. Installed plugins and
|
||||
// user-defined shortcuts must not change the reviewed Schema surface.
|
||||
// declarationOnly skips injectStaticServers / helpers.InitDeps so Schema
|
||||
// assembly cannot clobber a live process's ToolCaller or plugin endpoints.
|
||||
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
var rootCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
return newRootCommandWithEngine(rootCtx, nil, false)
|
||||
return newRootCommandWithEngine(rootCtx, nil, false, true)
|
||||
}
|
||||
|
||||
// NewRootCommandWithEngine constructs the root CLI command with an
|
||||
@@ -394,10 +396,10 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
// no pipeline processing is applied.
|
||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
registerSchemaRuntimeDelivery()
|
||||
return newRootCommandWithEngine(rootCtx, engine, true)
|
||||
return newRootCommandWithEngine(rootCtx, engine, true, false)
|
||||
}
|
||||
|
||||
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
|
||||
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
|
||||
if rootCtx == nil {
|
||||
rootCtx = context.Background()
|
||||
}
|
||||
@@ -488,7 +490,14 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
}
|
||||
root.AddCommand(utilityCommands...)
|
||||
|
||||
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
|
||||
if declarationOnly {
|
||||
// Schema / surface assembly: mount the reviewed tree only. Do not
|
||||
// injectStaticServers or InitDeps — those mutate process globals and
|
||||
// would clobber a live runtime's caller and plugin endpoints.
|
||||
root.AddCommand(mountLegacyPublicCommands(runner, loadRuntimeExtensions)...)
|
||||
} else {
|
||||
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
|
||||
}
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
|
||||
@@ -163,9 +163,16 @@ func commandShort(cmd *cobra.Command) string {
|
||||
|
||||
// resolveVisibleProducts returns the set of top-level product IDs that should
|
||||
// be treated as visible. It unions the edition's VisibleProducts hook (when
|
||||
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
|
||||
// including plugins loaded via AppendDynamicServer — are never silently hidden
|
||||
// by a static VisibleProducts list.
|
||||
// set), StaticServers product IDs, and DirectRuntimeProductIDs(), so
|
||||
// dynamically-registered products — including plugins loaded via
|
||||
// AppendDynamicServer — are never silently hidden by a static VisibleProducts
|
||||
// list.
|
||||
//
|
||||
// StaticServers are consulted directly (without injectStaticServers) so the
|
||||
// declaration-only Schema source root can keep reviewed products visible
|
||||
// without mutating the process-global dynamic endpoint registry.
|
||||
// SupplementServers stay out of this set: they are helper-only endpoints and
|
||||
// must not synthesize top-level product visibility.
|
||||
func resolveVisibleProducts() map[string]bool {
|
||||
allowed := map[string]bool{}
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
@@ -173,6 +180,13 @@ func resolveVisibleProducts() map[string]bool {
|
||||
allowed[p] = true
|
||||
}
|
||||
}
|
||||
if fn := edition.Get().StaticServers; fn != nil {
|
||||
for _, server := range fn() {
|
||||
if id := strings.TrimSpace(server.ID); id != "" {
|
||||
allowed[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range DirectRuntimeProductIDs() {
|
||||
allowed[id] = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOAApprovalDualModeConstraintsReachEmbeddedSchema(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
|
||||
tests := []struct {
|
||||
canonical string
|
||||
optional []string
|
||||
requireTogether []string
|
||||
mutuallyExclusive []string
|
||||
}{
|
||||
{
|
||||
canonical: "oa.forecast_process",
|
||||
optional: []string{"request", "process-code", "dept-id", "form-values"},
|
||||
requireTogether: []string{"process-code", "dept-id", "form-values"},
|
||||
mutuallyExclusive: []string{"process-code", "dept-id", "form-values"},
|
||||
},
|
||||
{
|
||||
canonical: "oa.start_process_instance",
|
||||
optional: []string{"request", "process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
|
||||
requireTogether: []string{"process-code", "form-values"},
|
||||
mutuallyExclusive: []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.canonical, func(t *testing.T) {
|
||||
tool := tools[test.canonical]
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for _, name := range test.optional {
|
||||
if got := parameters[name]["required"]; got != false {
|
||||
t.Errorf("--%s required = %#v, want false for dual-mode command", name, got)
|
||||
}
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"request", "process-code"})
|
||||
assertSchemaContractConstraintGroup(t, tool, "require_together", test.requireTogether)
|
||||
for _, name := range test.mutuallyExclusive {
|
||||
assertSchemaContractConstraintGroup(t, tool, "mutually_exclusive", []string{"request", name})
|
||||
}
|
||||
constraints, _ := tool["constraints"].(map[string]any)
|
||||
groups, _ := constraints["mutually_exclusive"].([]any)
|
||||
if len(groups) != len(test.mutuallyExclusive) {
|
||||
t.Errorf("mutually_exclusive group count = %d, want %d: %#v", len(groups), len(test.mutuallyExclusive), groups)
|
||||
}
|
||||
for _, rawGroup := range groups {
|
||||
group, _ := rawGroup.([]any)
|
||||
if len(group) != 2 {
|
||||
t.Errorf("mutually_exclusive contains an over-broad group: %#v", group)
|
||||
}
|
||||
}
|
||||
|
||||
hasRequestOnlyExample := false
|
||||
for _, example := range schemaContractStringSlice(tool["examples"]) {
|
||||
if strings.Contains(example, " --request ") && !strings.Contains(example, " --process-code ") && !strings.Contains(example, " --form-values ") {
|
||||
hasRequestOnlyExample = true
|
||||
}
|
||||
}
|
||||
if !hasRequestOnlyExample {
|
||||
t.Errorf("examples do not contain a request-only invocation: %#v", tool["examples"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
create := tools["oa.start_process_instance"]
|
||||
if got := schemaContractString(create["confirmation"]); got != "user_required" {
|
||||
t.Errorf("create-instance confirmation = %q, want user_required", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// schemaSourceRootMarkerCaller is a recognizable ToolCaller used to detect
|
||||
// InitDeps clobbering when NewSchemaSourceRootCommand builds the tree.
|
||||
type schemaSourceRootMarkerCaller struct{}
|
||||
|
||||
func (schemaSourceRootMarkerCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
func (schemaSourceRootMarkerCaller) Format() string { return "json" }
|
||||
func (schemaSourceRootMarkerCaller) DryRun() bool { return false }
|
||||
func (schemaSourceRootMarkerCaller) Fields() string { return "" }
|
||||
func (schemaSourceRootMarkerCaller) JQ() string { return "" }
|
||||
|
||||
// TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints ensures Schema
|
||||
// assembly (NewSchemaSourceRootCommand / ResolveMeta delivery) does not call
|
||||
// helpers.InitDeps or SetDynamicServers, which would wipe a live process's
|
||||
// caller and plugin endpoints.
|
||||
func TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints(t *testing.T) {
|
||||
const (
|
||||
pluginID = "schema-source-root-plugin-marker"
|
||||
pluginEndpoint = "https://schema-source-root-plugin.example/mcp"
|
||||
)
|
||||
|
||||
dynamicMu.RLock()
|
||||
previousEndpoints := dynamicEndpoints
|
||||
previousProducts := dynamicProducts
|
||||
previousAliases := dynamicAliases
|
||||
previousToolEndpoints := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = previousEndpoints
|
||||
dynamicProducts = previousProducts
|
||||
dynamicAliases = previousAliases
|
||||
dynamicToolEndpoints = previousToolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
marker := schemaSourceRootMarkerCaller{}
|
||||
helpers.InitDepsForTest(t, marker)
|
||||
SetDynamicServers(nil)
|
||||
AppendDynamicServer(mcptypes.ServerDescriptor{
|
||||
Key: pluginID,
|
||||
Endpoint: pluginEndpoint,
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: pluginID,
|
||||
Command: pluginID,
|
||||
},
|
||||
})
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
if root == nil {
|
||||
t.Fatal("NewSchemaSourceRootCommand returned nil")
|
||||
}
|
||||
|
||||
if got := helpers.GetCaller(); got != marker {
|
||||
t.Fatalf("helpers.GetCaller() after NewSchemaSourceRootCommand = %T (%p), want marker caller preserved", got, got)
|
||||
}
|
||||
gotEndpoint, ok := directRuntimeEndpoint(pluginID, "")
|
||||
if !ok || gotEndpoint != pluginEndpoint {
|
||||
t.Fatalf("plugin endpoint after NewSchemaSourceRootCommand = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
|
||||
}
|
||||
|
||||
resolved, err := cli.ResolveSchemaBuild(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSchemaBuild after declaration-only root: %v", err)
|
||||
}
|
||||
if resolved.CommandCount() == 0 {
|
||||
t.Fatal("ResolveSchemaBuild command count is 0")
|
||||
}
|
||||
|
||||
// Delivery path also builds NewSchemaSourceRootCommand via the factory.
|
||||
registerSchemaRuntimeDelivery()
|
||||
meta, ok := cli.ResolveMeta("dev app delete")
|
||||
if !ok || meta.Identity.Canonical == "" {
|
||||
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
|
||||
}
|
||||
if got := helpers.GetCaller(); got != marker {
|
||||
t.Fatalf("helpers.GetCaller() after ResolveMeta delivery = %T, want marker caller preserved", got)
|
||||
}
|
||||
gotEndpoint, ok = directRuntimeEndpoint(pluginID, "")
|
||||
if !ok || gotEndpoint != pluginEndpoint {
|
||||
t.Fatalf("plugin endpoint after ResolveMeta delivery = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject
|
||||
// proves the declaration-only Schema source root does not need
|
||||
// injectStaticServers to keep StaticServers products visible. VisibleProducts
|
||||
// may be unset; StaticServers alone must still prevent
|
||||
// hideNonDirectRuntimeCommands from marking those products Hidden — without
|
||||
// mutating dynamic endpoints.
|
||||
func TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(previous) })
|
||||
|
||||
dynamicMu.RLock()
|
||||
previousEndpoints := dynamicEndpoints
|
||||
previousProducts := dynamicProducts
|
||||
previousAliases := dynamicAliases
|
||||
previousToolEndpoints := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = previousEndpoints
|
||||
dynamicProducts = previousProducts
|
||||
dynamicAliases = previousAliases
|
||||
dynamicToolEndpoints = previousToolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "schema-source-root-static-visibility",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "", Name: "ignored-empty", Endpoint: "https://schema-source-root-static.example/empty"},
|
||||
{ID: " ", Name: "ignored-blank", Endpoint: "https://schema-source-root-static.example/blank"},
|
||||
{ID: "doc", Name: "Doc", Endpoint: "https://schema-source-root-static.example/doc"},
|
||||
}
|
||||
},
|
||||
// VisibleProducts intentionally nil: declaration-only visibility must
|
||||
// still derive from StaticServers without SetDynamicServers.
|
||||
})
|
||||
SetDynamicServers(nil)
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
var doc *cobra.Command
|
||||
for _, cmd := range root.Commands() {
|
||||
if cmd.Name() == "doc" {
|
||||
doc = cmd
|
||||
break
|
||||
}
|
||||
}
|
||||
if doc == nil {
|
||||
t.Fatal("doc command missing from Schema source root")
|
||||
}
|
||||
if doc.Hidden {
|
||||
t.Fatal("declaration-only Schema source root hid doc despite StaticServers")
|
||||
}
|
||||
// Endpoint resolution may still read StaticServers directly; the invariant
|
||||
// is that declaration-only construction must not mutate the dynamic registry.
|
||||
dynamicMu.RLock()
|
||||
_, injected := dynamicProducts["doc"]
|
||||
dynamicMu.RUnlock()
|
||||
if injected {
|
||||
t.Fatal("declaration-only Schema source root must not inject StaticServers into dynamicProducts")
|
||||
}
|
||||
}
|
||||
@@ -543,6 +543,13 @@ func TestIsLikelyAMFIKill(t *testing.T) {
|
||||
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
|
||||
// successfully re-executes the binary. We use go itself as a stand-in for the
|
||||
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
|
||||
//
|
||||
// GitHub's hosted macOS runners do not reproduce the amfid kill, so in CI this
|
||||
// test reports a skip that names the unverified path rather than implying the
|
||||
// self-heal was exercised. Set DWS_REQUIRE_AMFI_SELF_HEAL=1 on a host that does
|
||||
// enforce amfid to turn such a vacuous run into a hard failure.
|
||||
|
||||
const requireAMFISelfHealEnv = "DWS_REQUIRE_AMFI_SELF_HEAL"
|
||||
|
||||
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
@@ -573,15 +580,24 @@ func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
|
||||
t.Fatalf("strip signature: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Sanity: confirm direct exec is killed.
|
||||
// Sanity: confirm direct exec is killed. When it is not, repairDarwinBinary
|
||||
// never runs and the rest of this test proves nothing — say so.
|
||||
if _, err := tryExecVersion(bin); err == nil {
|
||||
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
|
||||
const unverified = "amfid did not kill the unsigned binary, so repairDarwinBinary was NOT exercised"
|
||||
if os.Getenv(requireAMFISelfHealEnv) == "1" {
|
||||
t.Fatalf("%s (%s=1)", unverified, requireAMFISelfHealEnv)
|
||||
}
|
||||
t.Skipf("%s — host does not enforce amfid (Intel Mac, SIP disabled, or hosted runner)", unverified)
|
||||
}
|
||||
|
||||
// validateNewBinary should self-heal and succeed.
|
||||
if err := validateNewBinary(bin, "dev"); err != nil {
|
||||
if strings.Contains(err.Error(), "signal: killed") {
|
||||
t.Skipf("host security policy still rejects the ad-hoc signed test binary: %v", err)
|
||||
const unverified = "host security policy still rejects the ad-hoc signed binary, so the self-heal outcome was NOT verified"
|
||||
if os.Getenv(requireAMFISelfHealEnv) == "1" {
|
||||
t.Fatalf("%s (%s=1): %v", unverified, requireAMFISelfHealEnv, err)
|
||||
}
|
||||
t.Skipf("%s: %v", unverified, err)
|
||||
}
|
||||
t.Fatalf("validateNewBinary did not recover: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package homology
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -19,8 +20,22 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// homologyProbeCaller is a throwaway ToolCaller for Execute-based confirmation
|
||||
// probes. Schema source roots stay declaration-only (no InitDeps); the probe
|
||||
// path initializes deps locally so leaf RunE wrappers do not nil-deref.
|
||||
type homologyProbeCaller struct{}
|
||||
|
||||
func (homologyProbeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
func (homologyProbeCaller) Format() string { return "json" }
|
||||
func (homologyProbeCaller) DryRun() bool { return false }
|
||||
func (homologyProbeCaller) Fields() string { return "" }
|
||||
func (homologyProbeCaller) JQ() string { return "" }
|
||||
|
||||
// TestUserRequiredSafetyHomologyWithRuntimeGate proves Catalog Safety and the
|
||||
// executable confirmation gate share one source for every live user_required leaf:
|
||||
//
|
||||
@@ -29,6 +44,12 @@ import (
|
||||
// Sheet protect marker, or framework NewCommand/Shortcut RunE (verified by
|
||||
// closed-stdin Execute → confirmation_required / 用户取消了操作 without --yes)
|
||||
func TestUserRequiredSafetyHomologyWithRuntimeGate(t *testing.T) {
|
||||
// Declaration-only Schema roots intentionally skip InitDeps. Probes Execute
|
||||
// live leaves (including deprecated doc wrappers), so install a local
|
||||
// throwaway caller without touching SetDynamicServers. Restore the prior
|
||||
// deps pointer (including nil) — InitDeps(previousCaller) cannot.
|
||||
helpers.InitDepsForTest(t, homologyProbeCaller{})
|
||||
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
if root.PersistentFlags().Lookup("yes") == nil {
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
|
||||
@@ -511,6 +511,17 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"minutes.record_resume --session-id": "Reviewed unpinned adapter: minutes.record_resume has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"minutes.record_start --session-id": "Reviewed unpinned adapter: minutes.record_start has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"minutes.record_stop --session-id": "Reviewed unpinned adapter: minutes.record_stop has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"oa.forecast_process --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
|
||||
"oa.forecast_process --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessForecastPopRequest, so it has no independent top-level MCP property.",
|
||||
"oa.forecast_process --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --approvers": "Conditional request wrapper: --approvers is transformed into an approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --approvers-action-type": "Conditional request wrapper: --approvers-action-type only configures the generated approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --cc-list": "Conditional request wrapper: --cc-list is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --cc-position": "Conditional request wrapper: --cc-position only configures the generated ccList inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --originator-user-id": "Conditional request wrapper: --originator-user-id is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
|
||||
"oa.start_process_instance --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
|
||||
"pat.batch_grant --domain": "aggregate/two-stage wrapper: --domain is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
|
||||
"pat.batch_grant --domains": "aggregate/two-stage wrapper: --domains is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
|
||||
"pat.batch_grant --product": "aggregate/two-stage wrapper: --product is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
|
||||
|
||||
@@ -885,7 +885,21 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
// Required is checked on the pre-transform string. A transform that
|
||||
// collapses separator-only input ("," / ";") to nil/empty must still
|
||||
// fail required flags locally — otherwise BuildArgs would omit the
|
||||
// key and ConfirmFirst write paths could reach the backend.
|
||||
if value == nil || emptyTransformResult(value) {
|
||||
if flag.Required {
|
||||
message := strings.TrimSpace(flag.RequiredHint)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(flag.RequiredError)
|
||||
}
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("必填参数 --%s 不能为空", flag.Name)
|
||||
}
|
||||
return nil, apperrors.NewValidation(message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
toolArgs[bind] = value
|
||||
@@ -896,6 +910,21 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
|
||||
return toolArgs, nil
|
||||
}
|
||||
|
||||
// emptyTransformResult reports whether a Transform produced an empty payload
|
||||
// that must not satisfy a Required flag (nil is handled by the caller).
|
||||
func emptyTransformResult(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v) == ""
|
||||
case []string:
|
||||
return len(v) == 0
|
||||
case []any:
|
||||
return len(v) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveValue reads the value by "explicit main flag → alias → env →
|
||||
// registration default" order (string form, integers uniformly formatted);
|
||||
// Trim TrimSpace's the result.
|
||||
|
||||
@@ -458,6 +458,70 @@ func TestCrossPlatformCoverageBuildArgsTransformAndErrors(t *testing.T) {
|
||||
t.Fatalf("nil transform should skip key: %#v", args)
|
||||
}
|
||||
|
||||
// Required + transform-to-empty must fail (separator-only lists)
|
||||
requiredEmpty := []FlagSpec{{
|
||||
Name: "codes", Usage: "C", Required: true, RequiredHint: "--codes 为必填",
|
||||
Transform: func(string) (any, error) { return nil, nil },
|
||||
}}
|
||||
cmd = newTestCommand()
|
||||
RegisterFlags(cmd, requiredEmpty)
|
||||
_ = cmd.Flags().Set("codes", ",")
|
||||
if _, err := BuildArgs(cmd, requiredEmpty); err == nil || !strings.Contains(err.Error(), "--codes 为必填") {
|
||||
t.Fatalf("required empty transform err = %v", err)
|
||||
}
|
||||
requiredEmptySlice := []FlagSpec{{
|
||||
Name: "codes", Usage: "C", Required: true,
|
||||
Transform: func(string) (any, error) { return []string{}, nil },
|
||||
}}
|
||||
cmd = newTestCommand()
|
||||
RegisterFlags(cmd, requiredEmptySlice)
|
||||
_ = cmd.Flags().Set("codes", ";")
|
||||
if _, err := BuildArgs(cmd, requiredEmptySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
|
||||
t.Fatalf("required empty-slice transform err = %v", err)
|
||||
}
|
||||
requiredEmptyString := []FlagSpec{{
|
||||
Name: "codes", Usage: "C", Required: true,
|
||||
Transform: func(string) (any, error) { return " ", nil },
|
||||
}}
|
||||
cmd = newTestCommand()
|
||||
RegisterFlags(cmd, requiredEmptyString)
|
||||
_ = cmd.Flags().Set("codes", ",")
|
||||
if _, err := BuildArgs(cmd, requiredEmptyString); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
|
||||
t.Fatalf("required empty-string transform err = %v", err)
|
||||
}
|
||||
requiredEmptyAnySlice := []FlagSpec{{
|
||||
Name: "codes", Usage: "C", Required: true,
|
||||
Transform: func(string) (any, error) { return []any{}, nil },
|
||||
}}
|
||||
cmd = newTestCommand()
|
||||
RegisterFlags(cmd, requiredEmptyAnySlice)
|
||||
_ = cmd.Flags().Set("codes", ",")
|
||||
if _, err := BuildArgs(cmd, requiredEmptyAnySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
|
||||
t.Fatalf("required empty []any transform err = %v", err)
|
||||
}
|
||||
requiredErrorHint := []FlagSpec{{
|
||||
Name: "codes", Usage: "C", Required: true, RequiredError: "codes missing after transform",
|
||||
Transform: func(string) (any, error) { return nil, nil },
|
||||
}}
|
||||
cmd = newTestCommand()
|
||||
RegisterFlags(cmd, requiredErrorHint)
|
||||
_ = cmd.Flags().Set("codes", ",")
|
||||
if _, err := BuildArgs(cmd, requiredErrorHint); err == nil || !strings.Contains(err.Error(), "codes missing after transform") {
|
||||
t.Fatalf("required RequiredError transform err = %v", err)
|
||||
}
|
||||
// Non-empty non-list transform result is kept (covers emptyTransformResult default).
|
||||
keepScalar := []FlagSpec{{
|
||||
Name: "n", Usage: "N", Required: true, Bind: "count",
|
||||
Transform: func(string) (any, error) { return 3, nil },
|
||||
}}
|
||||
cmd = newTestCommand()
|
||||
RegisterFlags(cmd, keepScalar)
|
||||
_ = cmd.Flags().Set("n", "x")
|
||||
args, err = BuildArgs(cmd, keepScalar)
|
||||
if err != nil || args["count"] != 3 {
|
||||
t.Fatalf("scalar transform args = %#v err = %v", args, err)
|
||||
}
|
||||
|
||||
// Transform error propagates
|
||||
boom := errors.New("transform boom")
|
||||
bad := []FlagSpec{{Name: "y", Usage: "Y", Transform: func(string) (any, error) { return nil, boom }}}
|
||||
|
||||
@@ -69,4 +69,103 @@ func TestCrossPlatformCoverageInterfaceSummaryRemainingEdges(t *testing.T) {
|
||||
if interfaceIdentifierOnly("含中文") {
|
||||
t.Fatal("non-ASCII summary classified as identifier-only")
|
||||
}
|
||||
if !interfaceIdentifierOnly("search_open_platform-docs.v1") {
|
||||
t.Fatal("identifier-only summary must be detected")
|
||||
}
|
||||
if got := summarizeInterfaceDescription("search_open_platform_docs", 40); got != "" {
|
||||
t.Fatalf("identifier description = %q, want empty", got)
|
||||
}
|
||||
if got := summarizeInterfaceDescription("**查询日历。** 第二句不保留。", 40); got != "查询日历。" {
|
||||
t.Fatalf("sentence summary = %q", got)
|
||||
}
|
||||
if got := summarizeInterfaceDescription("这是一个没有句号而且明显超过允许长度的接口描述文本", 12); got != "这是一个没有句号而..." {
|
||||
t.Fatalf("truncated summary = %q", got)
|
||||
}
|
||||
if got := summarizeInterfaceDescription("First half, then a very long continuation without a terminal mark", 20); !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("punctuation-cut summary = %q", got)
|
||||
}
|
||||
if got := interfaceSummarySource(interfaceMetadataFile{Source: " mcp-tools ", SourceRevision: "abcdef1234567890"}); got != "mcp-tools@abcdef123456" {
|
||||
t.Fatalf("interfaceSummarySource = %q", got)
|
||||
}
|
||||
if got := interfaceSummarySource(interfaceMetadataFile{}); got != "mcp-interface" {
|
||||
t.Fatalf("default interfaceSummarySource = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverallCoverageGapInterfaceMetadataFallbackSuccessPath(t *testing.T) {
|
||||
if err := applyInterfaceMetadataFallback(&File{}, nil, Options{}, &Stats{}, sourceTracker{}); err != nil {
|
||||
t.Fatalf("empty InterfaceMetadataPath error = %v", err)
|
||||
}
|
||||
if hasSurfaceAgentSummary(File{Tools: map[string]ToolMetadata{
|
||||
"blank": {AgentSummary: "", agentSummaryPresent: false},
|
||||
}}, nil, "blank") {
|
||||
t.Fatal("absent agent summary must not match")
|
||||
}
|
||||
if got := summarizeInterfaceDescription("first line\n\nsecond paragraph ignored", 80); got != "first line" {
|
||||
t.Fatalf("paragraph break summary = %q", got)
|
||||
}
|
||||
|
||||
display := filepath.ToSlash("interface.json")
|
||||
body := `{
|
||||
"version": 1,
|
||||
"source": "mcp-tools-list+cli-registry",
|
||||
"source_revision": "abcdef1234567890",
|
||||
"source_hash": "sha256:interface",
|
||||
"tools": {
|
||||
"calendar.get": {
|
||||
"description": "读取指定日历。",
|
||||
"interface_ref": {"product_id": "calendar", "rpc_name": "get"}
|
||||
},
|
||||
"calendar.list": {
|
||||
"description": "列出当前用户可访问的日历。后续句子不应进入 summary。"
|
||||
},
|
||||
"calendar.raw": {"description": "raw_tool_name"},
|
||||
"outside.tool": {"description": "不在公开命令面"}
|
||||
}
|
||||
}`
|
||||
files := map[string]sourceFile{display: {display: display, data: []byte(body)}}
|
||||
out := &File{Tools: map[string]ToolMetadata{
|
||||
"calendar.get": {AgentSummary: "已有摘要", agentSummaryPresent: true},
|
||||
}}
|
||||
stats := &Stats{}
|
||||
if err := applyInterfaceMetadataFallback(out, files, Options{
|
||||
InterfaceMetadataPath: "interface.json",
|
||||
ToolPaths: map[string]string{
|
||||
"calendar.get": "calendar get",
|
||||
"calendar.list": "calendar list",
|
||||
"calendar.raw": "calendar raw",
|
||||
},
|
||||
}, stats, sourceTracker{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audit := stats.InterfaceMetadata
|
||||
if audit == nil || audit.SourceTools != 4 || audit.SurfaceTools != 3 ||
|
||||
audit.EligibleSummaries != 2 || audit.AppliedSummaries != 1 ||
|
||||
audit.PreservedSummaries != 1 {
|
||||
t.Fatalf("interface audit = %#v", audit)
|
||||
}
|
||||
if len(audit.RejectedTools) != 1 || audit.RejectedTools[0] != "calendar.raw" ||
|
||||
len(audit.OutsideSurface) != 1 || audit.OutsideSurface[0] != "outside.tool" {
|
||||
t.Fatalf("interface audit paths = %#v", audit)
|
||||
}
|
||||
list := out.Tools["calendar.list"]
|
||||
if list.AgentSummary != "列出当前用户可访问的日历。" {
|
||||
t.Fatalf("applied MCP summary = %q", list.AgentSummary)
|
||||
}
|
||||
if list.AgentSummarySource != "mcp-tools-list+cli-registry@abcdef123456" {
|
||||
t.Fatalf("applied summary source = %q", list.AgentSummarySource)
|
||||
}
|
||||
if list.Reviewed == nil || *list.Reviewed {
|
||||
t.Fatalf("applied reviewed = %#v, want false", list.Reviewed)
|
||||
}
|
||||
if list.InterfaceRef != nil {
|
||||
t.Fatalf("list tool unexpectedly gained interface_ref: %#v", list.InterfaceRef)
|
||||
}
|
||||
get := out.Tools["calendar.get"]
|
||||
if get.InterfaceRef == nil || get.InterfaceRef.ProductID != "calendar" || get.InterfaceRef.RPCName != "get" {
|
||||
t.Fatalf("preserved tool interface_ref = %#v", get.InterfaceRef)
|
||||
}
|
||||
if get.AgentSummary != "已有摘要" {
|
||||
t.Fatalf("preserved summary overwritten: %#v", get)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +98,8 @@ func TestApprovalRevokeDryRunSkipsConfirmationAndEmitsPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
|
||||
caller := &contractDefectCaller{
|
||||
dryRun: true,
|
||||
responses: map[string]string{
|
||||
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
|
||||
},
|
||||
}
|
||||
func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) {
|
||||
caller := &contractDefectCaller{dryRun: true}
|
||||
output, err := executeContractDefectCommand(t, caller, newDocCommand,
|
||||
"version", "revert", "--node", "node-dry-run", "--version", "7", "--dry-run")
|
||||
if err != nil {
|
||||
@@ -113,10 +108,8 @@ func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
|
||||
}
|
||||
if len(caller.readCalls) != 1 ||
|
||||
caller.readCalls[0].productID != "doc" ||
|
||||
caller.readCalls[0].toolName != "list_doc_versions" {
|
||||
t.Fatalf("dry-run read calls = %#v, want doc/list_doc_versions", caller.readCalls)
|
||||
if len(caller.readCalls) != 0 {
|
||||
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
|
||||
}
|
||||
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
|
||||
!strings.Contains(output, `"version": 7`) {
|
||||
@@ -124,30 +117,46 @@ func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocVersionRevertDryRunRejectsMissingVersionBeforePreview(t *testing.T) {
|
||||
caller := &contractDefectCaller{
|
||||
dryRun: true,
|
||||
responses: map[string]string{
|
||||
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
|
||||
},
|
||||
}
|
||||
func TestDocVersionRevertDryRunDoesNotRejectMissingVersionRemotely(t *testing.T) {
|
||||
caller := &contractDefectCaller{dryRun: true}
|
||||
output, err := executeContractDefectCommand(t, caller, newDocCommand,
|
||||
"version", "revert", "--node", "node-dry-run", "--version", "999", "--dry-run")
|
||||
if err == nil || !strings.Contains(err.Error(), "文档版本 999 不存在") {
|
||||
t.Fatalf("missing version error = %v, want explicit rejection", err)
|
||||
if err != nil {
|
||||
t.Fatalf("doc version revert dry-run returned error: %v", err)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "version_not_found" {
|
||||
t.Fatalf("missing version error = %#v, want typed version_not_found", err)
|
||||
}
|
||||
if len(caller.readCalls) != 1 {
|
||||
t.Fatalf("dry-run read calls = %#v, want one version lookup", caller.readCalls)
|
||||
if len(caller.readCalls) != 0 {
|
||||
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
|
||||
}
|
||||
if strings.Contains(output, `"tool": "revert_doc_version"`) {
|
||||
t.Fatalf("missing version emitted a misleading mutation preview: %q", output)
|
||||
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
|
||||
!strings.Contains(output, `"version": 999`) {
|
||||
t.Fatalf("dry-run output = %q, want preview without remote missing-version rejection", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocVersionRevertNonDryRunPreflightsVersion(t *testing.T) {
|
||||
caller := &contractDefectCaller{
|
||||
responses: map[string]string{
|
||||
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
|
||||
"doc/revert_doc_version": `{}`,
|
||||
},
|
||||
}
|
||||
output, err := executeContractDefectCommand(t, caller, newDocCommand,
|
||||
"version", "revert", "--node", "node-live", "--version", "7", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("doc version revert returned error: %v", err)
|
||||
}
|
||||
// Outside --dry-run, list_doc_versions rides the normal CallTool channel
|
||||
// (CallReadTool is dry-run-only). Expect preflight then mutation.
|
||||
if len(caller.calls) != 2 ||
|
||||
caller.calls[0].productID != "doc" || caller.calls[0].toolName != "list_doc_versions" ||
|
||||
caller.calls[1].productID != "doc" || caller.calls[1].toolName != "revert_doc_version" {
|
||||
t.Fatalf("non-dry-run calls = %#v, want list_doc_versions then revert_doc_version", caller.calls)
|
||||
}
|
||||
if strings.Contains(output, `"dry_run": true`) {
|
||||
t.Fatalf("non-dry-run output unexpectedly previewed: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2232,7 +2232,9 @@ func splitDevAppList(raw string) []string {
|
||||
}
|
||||
|
||||
// transformDevAppListParam splits a comma/semicolon list for LeafFlag.Transform.
|
||||
// Empty input returns nil so the key is omitted from toolArgs.
|
||||
// Empty input (including separator-only) returns nil so optional flags omit the
|
||||
// key from toolArgs. Required flags that collapse to empty are rejected by
|
||||
// corecmd.BuildArgs after transform.
|
||||
func transformDevAppListParam(raw string) (any, error) {
|
||||
values := splitDevAppList(raw)
|
||||
if len(values) == 0 {
|
||||
|
||||
@@ -709,6 +709,33 @@ func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppEventSubscribeRejectsSeparatorOnlyEventCodes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"subscribe_comma", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
|
||||
{"subscribe_semicolon", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ";", "--yes"}},
|
||||
{"unsubscribe_comma", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
root := newDevAppTestRoot(runner)
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--event-codes 为必填") {
|
||||
t.Fatalf("Execute() error = %v, want --event-codes 为必填", err)
|
||||
}
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("runner should not be called, got tool %q", runner.last.Tool)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevAppWebappCommandsBuildParams(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
+29
-17
@@ -3911,20 +3911,22 @@ CLI 内部自动完成全部流程:
|
||||
return fmt.Errorf("flag --version is required")
|
||||
}
|
||||
version, _ := cmd.Flags().GetInt("version")
|
||||
exists, err := docVersionExists(cmd.Context(), nodeID, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
|
||||
apperrors.WithReason("version_not_found"),
|
||||
apperrors.WithHint(fmt.Sprintf(
|
||||
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
|
||||
nodeID,
|
||||
)),
|
||||
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
|
||||
)
|
||||
if !commandDryRun(cmd) {
|
||||
exists, err := docVersionExists(cmd.Context(), nodeID, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
|
||||
apperrors.WithReason("version_not_found"),
|
||||
apperrors.WithHint(fmt.Sprintf(
|
||||
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
|
||||
nodeID,
|
||||
)),
|
||||
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
|
||||
)
|
||||
}
|
||||
}
|
||||
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
@@ -4230,6 +4232,16 @@ CLI 内部自动完成全部流程:
|
||||
return root
|
||||
}
|
||||
|
||||
// printDocDeprecationWarning emits a deprecation warning when shared deps are
|
||||
// initialized. Schema declaration-only roots skip InitDeps; homology and other
|
||||
// Execute probes must remain nil-safe on that path.
|
||||
func printDocDeprecationWarning(msg string) {
|
||||
if deps == nil || deps.Out == nil {
|
||||
return
|
||||
}
|
||||
deps.Out.PrintWarning(msg)
|
||||
}
|
||||
|
||||
// wrapDocDeprecated wraps a doc command's RunE to print a deprecation warning
|
||||
// directing users to the corresponding drive command. The original command
|
||||
// continues to function normally during the transition period.
|
||||
@@ -4237,7 +4249,7 @@ func wrapDocDeprecated(cmd *cobra.Command, driveSubCmd string) {
|
||||
originalRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
printDocDeprecationWarning(fmt.Sprintf(
|
||||
"⚠️ 'dws doc %s' is deprecated, use 'dws drive %s' instead.",
|
||||
c.CommandPath()[8:], // strip "dws doc " prefix
|
||||
driveSubCmd,
|
||||
@@ -4253,7 +4265,7 @@ func wrapDocDeprecatedToWiki(cmd *cobra.Command, wikiSubCmd string) {
|
||||
originalRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
printDocDeprecationWarning(fmt.Sprintf(
|
||||
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
|
||||
c.CommandPath()[8:],
|
||||
wikiSubCmd,
|
||||
@@ -4269,7 +4281,7 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
|
||||
originalRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
printDocDeprecationWarning(fmt.Sprintf(
|
||||
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
|
||||
c.CommandPath()[8:],
|
||||
targetCmd,
|
||||
|
||||
@@ -173,6 +173,24 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) {
|
||||
_ = cmd.RunE(cmd, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Declaration-only Schema roots leave deps nil; wrappers must not panic.
|
||||
deps = nil
|
||||
for _, wrap := range []func(*cobra.Command){
|
||||
func(cmd *cobra.Command) { wrapDocDeprecated(cmd, "drive target") },
|
||||
func(cmd *cobra.Command) { wrapDocDeprecatedToWiki(cmd, "wiki target") },
|
||||
func(cmd *cobra.Command) { wrapDocDeprecatedToTarget(cmd, "target") },
|
||||
} {
|
||||
cmd := &cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
wrap(cmd)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
doc := &cobra.Command{Use: "doc"}
|
||||
root.AddCommand(doc)
|
||||
doc.AddCommand(cmd)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("nil-deps deprecation wrapper: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Test helpers for package-level deps. Production code must not call these;
|
||||
// the ForTest suffix is the boundary.
|
||||
|
||||
// InitDepsForTest installs caller for the test duration and restores the prior
|
||||
// deps pointer (including a prior nil) via testseam. Prefer this over
|
||||
// InitDeps + InitDeps(previousCaller): restoring through InitDeps(nil) leaves a
|
||||
// non-nil Deps with a nil Caller, which is not the original unset state.
|
||||
func InitDepsForTest(t *testing.T, caller edition.ToolCaller) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type fortestCoverageCaller struct{}
|
||||
|
||||
func (fortestCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
func (fortestCoverageCaller) Format() string { return "json" }
|
||||
func (fortestCoverageCaller) DryRun() bool { return false }
|
||||
func (fortestCoverageCaller) Fields() string { return "" }
|
||||
func (fortestCoverageCaller) JQ() string { return "" }
|
||||
|
||||
// TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps covers the
|
||||
// ForTest deps restore helper used by declaration-only Schema probes.
|
||||
func TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps(t *testing.T) {
|
||||
testseam.Protect(t, &deps)
|
||||
deps = nil
|
||||
|
||||
marker := fortestCoverageCaller{}
|
||||
InitDepsForTest(t, marker)
|
||||
if got := GetCaller(); got != marker {
|
||||
t.Fatalf("GetCaller() during InitDepsForTest = %T, want marker", got)
|
||||
}
|
||||
if deps == nil {
|
||||
t.Fatal("InitDepsForTest left deps nil before test cleanup")
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,15 @@ func TestLeafArgs(t *testing.T) {
|
||||
|
||||
func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
|
||||
cmd := NewLeafCommand(leafTestSpec())
|
||||
// Satisfy Required flags first: BuildArgs now rejects Required transforms that
|
||||
// collapse to empty (unset CSV), matching runtime behavior after ValidateRequired.
|
||||
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
|
||||
if err := cmd.Flags().Set("users", "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("content", "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args, err := corecmd.BuildArgs(cmd, leafTestSpec().Flags)
|
||||
if err != nil {
|
||||
t.Fatalf("leafArgs() error = %v", err)
|
||||
@@ -239,10 +248,8 @@ func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
|
||||
if _, present := args["cursor"]; present {
|
||||
t.Fatalf("cursor present = %v, want omitted when zero", args["cursor"])
|
||||
}
|
||||
// 未配置 OmitEmpty 的 flag 即使为空也入参(复现手写语义;Required 校验在
|
||||
// leafArgs 之前执行,保证真实路径不会发出空值)。
|
||||
if v, present := args["accessToken"]; !present || v != "" {
|
||||
t.Fatalf("accessToken = %v/%v, want present-but-empty without OmitEmpty", v, present)
|
||||
if v, present := args["accessToken"]; !present || v != "tok" {
|
||||
t.Fatalf("accessToken = %v/%v, want env tok", v, present)
|
||||
}
|
||||
// 未设置 OmitEmpty 的字符串即使为空也入参(复现手写 remindType 恒入参语义)。
|
||||
if v, present := args["remindType"]; !present || v != "app" {
|
||||
|
||||
+267
-1
@@ -1,21 +1,55 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func decodeOARequest(raw string) (map[string]any, error) {
|
||||
dec := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
dec.UseNumber()
|
||||
var request map[string]any
|
||||
if err := dec.Decode(&request); err != nil || request == nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("JSON 请求不能为 null")
|
||||
}
|
||||
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
|
||||
return nil, fmt.Errorf("JSON 请求包含多余内容")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func oaFormValues(raw string) ([]map[string]string, error) {
|
||||
var values map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]map[string]string, 0, len(values))
|
||||
for name, value := range values {
|
||||
result = append(result, map[string]string{"name": name, "value": value})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws oa — OA 审批
|
||||
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
|
||||
// approve_processInstance, reject_processInstance, revoke_processInstance,
|
||||
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
|
||||
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
|
||||
// get_inst_revert_activities
|
||||
// get_inst_revert_activities, get_process_schema, forecast_process,
|
||||
// start_process_instance
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newOaCommand() *cobra.Command {
|
||||
@@ -1017,6 +1051,195 @@ func newOaCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
approvalFormSchemaCmd := &cobra.Command{
|
||||
Use: "form-schema", Short: "查询审批模板的表单 Schema",
|
||||
Example: "dws oa approval form-schema --process-code <processCode>",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "process-code"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_process_schema", map[string]any{"processCode": mustGetFlag(cmd, "process-code")})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(approvalFormSchemaCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "get_process_schema",
|
||||
CanonicalPath: "oa.get_process_schema",
|
||||
CLIPath: "oa approval form-schema",
|
||||
PrimaryCLIPath: "oa approval form-schema",
|
||||
},
|
||||
Description: "查询审批模板的表单 Schema",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_schema"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询审批模板的表单 Schema",
|
||||
UseWhen: []string{"已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"},
|
||||
AvoidWhen: []string{"只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"},
|
||||
Examples: []string{"dws oa approval form-schema --process-code <processCode>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "process-code", Property: "processCode"},
|
||||
},
|
||||
},
|
||||
})
|
||||
approvalForecastCmd := &cobra.Command{
|
||||
Use: "forecast-process", Short: "根据表单值预测审批流程与自选节点",
|
||||
Example: "dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
|
||||
request, err := decodeOARequest(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--request JSON 解析失败: %w", err)
|
||||
}
|
||||
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": request})
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "process-code", "dept-id", "form-values"); err != nil {
|
||||
return err
|
||||
}
|
||||
deptID, err := strconv.ParseInt(mustGetFlag(cmd, "dept-id"), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--dept-id 必须为整数: %w", err)
|
||||
}
|
||||
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
|
||||
}
|
||||
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "deptId": deptID, "formComponentValues": [][]map[string]string{values}}})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(approvalForecastCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "forecast_process",
|
||||
CanonicalPath: "oa.forecast_process",
|
||||
CLIPath: "oa approval forecast-process",
|
||||
PrimaryCLIPath: "oa approval forecast-process",
|
||||
},
|
||||
Description: "根据表单值预测审批流程与自选节点",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "forecast_process"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "预测审批流程与自选审批节点",
|
||||
UseWhen: []string{"已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"},
|
||||
AvoidWhen: []string{"需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"},
|
||||
Examples: []string{
|
||||
"dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
|
||||
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'",
|
||||
},
|
||||
},
|
||||
// Simple-mode flags are mapping exclusions (encoded inside ProcessForecastPopRequest).
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "request", Property: "ProcessForecastPopRequest", InterfaceType: "object"},
|
||||
},
|
||||
},
|
||||
})
|
||||
approvalCreateCmd := &cobra.Command{
|
||||
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
|
||||
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !commandDryRun(cmd) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if !yes {
|
||||
return fmt.Errorf("发起审批实例会创建真实业务数据;请先核对参数,然后添加 --yes 确认执行")
|
||||
}
|
||||
}
|
||||
var request map[string]any
|
||||
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
|
||||
var err error
|
||||
request, err = decodeOARequest(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--request JSON 解析失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "process-code", "form-values"); err != nil {
|
||||
return err
|
||||
}
|
||||
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
|
||||
}
|
||||
request = map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "formComponentValues": values}
|
||||
if dept, _ := cmd.Flags().GetString("dept-id"); dept != "" {
|
||||
value, err := strconv.ParseInt(dept, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--dept-id 必须为整数: %w", err)
|
||||
}
|
||||
request["deptId"] = value
|
||||
}
|
||||
if userID, _ := cmd.Flags().GetString("originator-user-id"); userID != "" {
|
||||
request["originatorUserId"] = userID
|
||||
}
|
||||
if rawApprovers, _ := cmd.Flags().GetString("approvers"); rawApprovers != "" {
|
||||
action, _ := cmd.Flags().GetString("approvers-action-type")
|
||||
if action != "AND" && action != "OR" && action != "NONE" {
|
||||
return fmt.Errorf("--approvers-action-type 必须为 AND、OR 或 NONE")
|
||||
}
|
||||
request["approvers"] = []map[string]any{{"actionType": action, "userIds": strings.Split(rawApprovers, ",")}}
|
||||
}
|
||||
if rawCC, _ := cmd.Flags().GetString("cc-list"); rawCC != "" {
|
||||
position, _ := cmd.Flags().GetString("cc-position")
|
||||
if position != "START" && position != "FINISH" && position != "START_FINISH" {
|
||||
return fmt.Errorf("--cc-position 必须为 START、FINISH 或 START_FINISH")
|
||||
}
|
||||
request["ccList"] = strings.Split(rawCC, ",")
|
||||
request["ccPosition"] = position
|
||||
}
|
||||
}
|
||||
return callMCPTool("start_process_instance", map[string]any{"ProcessInstanceCreationPopRequest": request})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(approvalCreateCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "start_process_instance",
|
||||
CanonicalPath: "oa.start_process_instance",
|
||||
CLIPath: "oa approval create-instance",
|
||||
PrimaryCLIPath: "oa approval create-instance",
|
||||
},
|
||||
Description: "发起审批实例(需要 --yes 确认)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "start_process_instance"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "发起新的审批实例",
|
||||
UseWhen: []string{"用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"},
|
||||
AvoidWhen: []string{"只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"},
|
||||
Examples: []string{
|
||||
"dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}'",
|
||||
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'",
|
||||
},
|
||||
},
|
||||
// Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceCreationPopRequest).
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "request", Property: "ProcessInstanceCreationPopRequest", InterfaceType: "object"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
approvalListPendingCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
|
||||
approvalListPendingCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
|
||||
approvalListPendingCmd.Flags().String("page", "", "分页页码 (可选)")
|
||||
@@ -1087,6 +1310,46 @@ func newOaCommand() *cobra.Command {
|
||||
approvalRevertTaskCmd.Flags().String("target-activity-id", "", "退回到的节点 ID(退回发起人固定传 sid-startevent)(必填)")
|
||||
approvalRevertTaskCmd.Flags().String("action", "", "退回方式:REVERT_FOR_APPROVAL(退回到审批人)/ REVERT_FOR_RESUBMIT(退回到发起人)(必填)")
|
||||
approvalRevertTaskCmd.Flags().String("remark", "", "退回说明 (可选)")
|
||||
approvalFormSchemaCmd.Flags().String("process-code", "", "审批模板 processCode (必填)")
|
||||
approvalForecastCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
|
||||
approvalForecastCmd.Flags().String("dept-id", "", "发起人部门 ID(简单模式使用;与 --request 互斥)")
|
||||
approvalForecastCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
|
||||
approvalForecastCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
|
||||
approvalForecastCmd.MarkFlagsOneRequired("request", "process-code")
|
||||
approvalForecastCmd.MarkFlagsRequiredTogether("process-code", "dept-id", "form-values")
|
||||
forecastMutuallyExclusive := make([][]string, 0, 3)
|
||||
for _, name := range []string{"process-code", "dept-id", "form-values"} {
|
||||
approvalForecastCmd.MarkFlagsMutuallyExclusive("request", name)
|
||||
forecastMutuallyExclusive = append(forecastMutuallyExclusive, []string{"request", name})
|
||||
}
|
||||
cli.AnnotateRuntimeConstraints(approvalForecastCmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: forecastMutuallyExclusive,
|
||||
RequireOneOf: [][]string{{"request", "process-code"}},
|
||||
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
|
||||
})
|
||||
|
||||
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
|
||||
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
|
||||
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
|
||||
approvalCreateCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
|
||||
approvalCreateCmd.Flags().String("originator-user-id", "", "审批发起人 userId")
|
||||
approvalCreateCmd.Flags().String("approvers", "", "审批人 userId 列表,多个用逗号分隔")
|
||||
approvalCreateCmd.Flags().String("approvers-action-type", "OR", "审批类型:AND、OR 或 NONE")
|
||||
approvalCreateCmd.Flags().String("cc-list", "", "抄送人 userId 列表,多个用逗号分隔")
|
||||
approvalCreateCmd.Flags().String("cc-position", "START", "抄送时点:START、FINISH 或 START_FINISH")
|
||||
approvalCreateCmd.MarkFlagsOneRequired("request", "process-code")
|
||||
approvalCreateCmd.MarkFlagsRequiredTogether("process-code", "form-values")
|
||||
createSimpleFlags := []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"}
|
||||
createMutuallyExclusive := make([][]string, 0, len(createSimpleFlags))
|
||||
for _, name := range createSimpleFlags {
|
||||
approvalCreateCmd.MarkFlagsMutuallyExclusive("request", name)
|
||||
createMutuallyExclusive = append(createMutuallyExclusive, []string{"request", name})
|
||||
}
|
||||
cli.AnnotateRuntimeConstraints(approvalCreateCmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: createMutuallyExclusive,
|
||||
RequireOneOf: [][]string{{"request", "process-code"}},
|
||||
RequireTogether: [][]string{{"process-code", "form-values"}},
|
||||
})
|
||||
|
||||
approvalCmd.AddCommand(
|
||||
approvalListPendingCmd,
|
||||
@@ -1109,6 +1372,9 @@ func newOaCommand() *cobra.Command {
|
||||
approvalAppendTaskCmd,
|
||||
approvalRevertActivitiesCmd,
|
||||
approvalRevertTaskCmd,
|
||||
approvalFormSchemaCmd,
|
||||
approvalForecastCmd,
|
||||
approvalCreateCmd,
|
||||
)
|
||||
root.AddCommand(approvalCmd)
|
||||
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func executeOACommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previous := deps
|
||||
previousArgs := os.Args
|
||||
os.Args = []string{"dws", "oa"}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = previous
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
cmd := newOaCommand()
|
||||
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
@@ -31,3 +59,143 @@ func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
|
||||
t.Fatalf("revert task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalCreateInstanceMapsInternalSimpleOptions(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "create-instance",
|
||||
"--process-code", "PROC",
|
||||
"--form-values", `{"事由":"测试"}`,
|
||||
"--originator-user-id", "originator",
|
||||
"--approvers", "approver-1,approver-2",
|
||||
"--approvers-action-type", "AND",
|
||||
"--cc-list", "cc-1,cc-2",
|
||||
"--cc-position", "FINISH",
|
||||
"--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create instance: %v", err)
|
||||
}
|
||||
if caller.server != "oa" || caller.tool != "start_process_instance" {
|
||||
t.Fatalf("called %s/%s, want oa/start_process_instance", caller.server, caller.tool)
|
||||
}
|
||||
request, ok := caller.args["ProcessInstanceCreationPopRequest"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("request payload = %#v", caller.args)
|
||||
}
|
||||
if got := request["originatorUserId"]; got != "originator" {
|
||||
t.Fatalf("originatorUserId = %#v", got)
|
||||
}
|
||||
approvers, ok := request["approvers"].([]map[string]any)
|
||||
if !ok || len(approvers) != 1 || approvers[0]["actionType"] != "AND" {
|
||||
t.Fatalf("approvers = %#v", request["approvers"])
|
||||
}
|
||||
if got := approvers[0]["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "approver-1" || got.([]string)[1] != "approver-2" {
|
||||
t.Fatalf("approver userIds = %#v", got)
|
||||
}
|
||||
if got := request["ccList"]; len(got.([]string)) != 2 || got.([]string)[0] != "cc-1" || got.([]string)[1] != "cc-2" {
|
||||
t.Fatalf("ccList = %#v", got)
|
||||
}
|
||||
if got := request["ccPosition"]; got != "FINISH" {
|
||||
t.Fatalf("ccPosition = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalCreateInstanceRejectsMixedRequestModes(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "create-instance",
|
||||
"--request", `{"processCode":"PROC"}`,
|
||||
"--process-code", "PROC",
|
||||
"--yes",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("mixed request modes returned nil")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("unexpected MCP call count: %d", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "create-instance",
|
||||
"--request", `{"processCode":"PROC"}`,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("create instance without --yes error = %v, want explicit --yes requirement", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("create instance without --yes made %d MCP calls", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
|
||||
validCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
}{
|
||||
{
|
||||
name: "form schema",
|
||||
args: []string{"approval", "form-schema", "--process-code", "PROC"},
|
||||
tool: "get_process_schema",
|
||||
},
|
||||
{
|
||||
name: "forecast simple mode",
|
||||
args: []string{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", `{"金额":"100"}`},
|
||||
tool: "forecast_process",
|
||||
},
|
||||
{
|
||||
name: "forecast request mode",
|
||||
args: []string{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`},
|
||||
tool: "forecast_process",
|
||||
},
|
||||
{
|
||||
name: "create request mode",
|
||||
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
|
||||
tool: "start_process_instance",
|
||||
},
|
||||
}
|
||||
for _, tc := range validCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := executeOACommand(t, caller, tc.args...); err != nil {
|
||||
t.Fatalf("execute %v: %v", tc.args, err)
|
||||
}
|
||||
if caller.tool != tc.tool || caller.calls != 1 {
|
||||
t.Fatalf("called tool=%q calls=%d, want %q once", caller.tool, caller.calls, tc.tool)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalidCases := [][]string{
|
||||
{"approval", "form-schema"},
|
||||
{"approval", "forecast-process"},
|
||||
{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
|
||||
{"approval", "forecast-process", "--request", "{"},
|
||||
{"approval", "forecast-process", "--request", "null"},
|
||||
{"approval", "forecast-process", "--request", "{} {}"},
|
||||
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "bad", "--form-values", `{"金额":"100"}`},
|
||||
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", "["},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`},
|
||||
{"approval", "create-instance", "--yes"},
|
||||
{"approval", "create-instance", "--request", "{", "--yes"},
|
||||
{"approval", "create-instance", "--request", "null", "--yes"},
|
||||
{"approval", "create-instance", "--request", "{} {}", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", "[", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
|
||||
}
|
||||
for _, args := range invalidCases {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := executeOACommand(t, caller, args...); err == nil {
|
||||
t.Fatalf("invalid args %v returned nil", args)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid args %v made %d MCP calls", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,16 @@ type scriptedToolCaller struct {
|
||||
format string
|
||||
dry bool
|
||||
calls int
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls++
|
||||
c.server = serverID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
if len(c.steps) == 0 {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
@@ -117,6 +117,363 @@ Flags:
|
||||
--query string 关键字,匹配 processCode 或表单名称 (必填)
|
||||
```
|
||||
|
||||
### 按模板 processCode 查询表单 Schema 信息
|
||||
|
||||
> **说明:** 根据已知的 processCode 精确查询表单的完整 Schema,包括表单名称、状态、创建者、创建/修改时间以及表单组件 JSON(content 字段)。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws oa approval form-schema [flags]
|
||||
Example:
|
||||
dws oa approval form-schema --process-code PROC-594AE140-6AA5-4BA4-AF0C-9E6F66DB1E0B
|
||||
Flags:
|
||||
--process-code string 表单模板 processCode (必填)
|
||||
```
|
||||
|
||||
返回值字段:
|
||||
- `result.processName` — 表单名称
|
||||
- `result.processCode` — 表单 processCode
|
||||
- `result.processStatus` — 表单状态(如 `PUBLISHED`)
|
||||
- `result.creator` — 创建者 userId
|
||||
- `result.gmtCreate` / `result.gmtModified` — 创建/修改时间(毫秒时间戳)
|
||||
- `result.processIconUrl` — 表单图标 URL
|
||||
- `result.processDescription` — 表单描述
|
||||
- `result.content` — 表单组件 JSON 字符串,包含表单项(items)和标题等配置
|
||||
|
||||
### 流程预测
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws oa approval forecast-process [flags]
|
||||
Example:
|
||||
# 简单预测
|
||||
dws oa approval forecast-process --process-code PROC-xxx --dept-id -1 --form-values '{"单行输入框":"测试内容"}'
|
||||
# 指定部门预测
|
||||
dws oa approval forecast-process --process-code PROC-xxx --dept-id 12345 --form-values '{"金额":"5000"}'
|
||||
# 高级用法:传入完整 JSON
|
||||
dws oa approval forecast-process --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[[{"name":"单行输入框","value":"测试"}]]}'
|
||||
Flags:
|
||||
--process-code string 审批模板 processCode(简单模式必填)
|
||||
--form-values string 表单值 JSON,格式 '{"控件名称":"值"}'(简单模式必填)
|
||||
--dept-id string 发起人所在部门 ID,根部门填 -1(简单模式必填)
|
||||
--request string 完整请求体 JSON(高级模式,与简单模式互斥)
|
||||
```
|
||||
|
||||
> **注意:** forecast 接口的 `formComponentValues` 比 create-instance 多一层数组包裹(`[[{...}]]`),CLI 简单模式已自动处理,高级模式需自行包裹。`processCode`、`deptId`、`formComponentValues` 三个字段均为必填,`userId` 由系统从登录态自动填充。
|
||||
|
||||
#### 流程预测的作用
|
||||
|
||||
在 `create-instance` 之前调用 `forecast-process`,可以根据已填写的表单值预测审批流程走向,核心价值有两个:
|
||||
|
||||
1. **展示流程路径** — 告诉用户这个审批会经过哪些节点(审批人、抄送人、条件分支),让用户在提交前就知道流程走向。
|
||||
2. **识别自选审批人节点** — 返回中 `targetSelect: true` 的节点需要用户手动选择审批人/抄送人,Agent 应提示用户选人,并将结果传入 `create-instance` 的 `targetSelectActioners`。
|
||||
|
||||
#### 返回值关键字段
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `result.forecastSuccess` | 预测是否成功 |
|
||||
| `result.staticWorkflow` | 是否为静态流程(无条件分支) |
|
||||
| `result.workflowForecastNodes` | 流程节点路径,每个节点包含 `activityId` 和 `outIds`(下一跳) |
|
||||
| `result.workflowActivityRuleVOs` | **重点**:每个节点的详细规则,包含节点类型、审批人、是否自选等 |
|
||||
|
||||
#### `workflowActivityRuleVOs` 节点字段解读
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `activityId` | 节点 ID |
|
||||
| `workflowActor.actorKey` | 自选节点的规则 key,即 `targetSelectActioners` 中 `actionerKey` 的值 |
|
||||
| `activityName` | 节点名称(如"审批人"、"抄送人") |
|
||||
| `activityType` | 节点类型:`target_approval`(已指定审批人)、`target_select`(需自选)、`target_notifier`(抄送) |
|
||||
| `targetSelect` | **`true` 表示需要用户自选审批人/抄送人** |
|
||||
| `activityActioners` | 已确定的处理人列表(含 `emplId`、`name`) |
|
||||
| `workflowActor.actorType` | 角色类型:`approver`(审批人)、`notifier`(抄送人) |
|
||||
| `workflowActor.approvalMethod` | 多人审批方式:`ONE_BY_ONE`(依次审批) |
|
||||
| `workflowActor.actorSelectionType` | 选人范围:`allStaff`(全员可选)等 |
|
||||
| `prevActivityId` | 上一节点 ID |
|
||||
|
||||
#### Agent 处理流程
|
||||
|
||||
```
|
||||
1. 调用 forecast-process,传入 processCode + form-values
|
||||
2. 遍历 workflowActivityRuleVOs:
|
||||
a. 向用户展示每个节点的名称、类型、已指定处理人
|
||||
b. 若 targetSelect == true:
|
||||
- 提示用户"节点「{activityName}」需要您自选{actorType}人"
|
||||
- 使用 dws aisearch person --keyword "<姓名>" --dimension name --format json 帮用户查找并选人
|
||||
- 记录 activityId 和用户选择的 userIds
|
||||
3. 将自选结果组装为 targetSelectActioners,传入 create-instance 高级模式 --request
|
||||
```
|
||||
|
||||
#### 自选节点 → `targetSelectActioners` 组装示例
|
||||
|
||||
假设 forecast 返回两个自选节点:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetSelectActioners": [
|
||||
{
|
||||
"actionerKey": "manual_33ff_89cb_da91_e3aa",
|
||||
"actionerStaffIds": ["userId_选人A"]
|
||||
},
|
||||
{
|
||||
"actionerKey": "manual_a29e_9633_f8b7_7291",
|
||||
"actionerStaffIds": ["userId_选人B"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
此字段通过 `create-instance --request` 的高级模式传入。`actionerKey` 来自 forecast 返回的 `workflowActor.actorKey`。
|
||||
|
||||
### 发起审批实例
|
||||
|
||||
#### 执行摘要
|
||||
|
||||
- **如果用户未明确给出 `processCode`,必须固定走 `search-forms` → `form-schema` → 收集表单值 → `forecast-process` → 自选节点选人 → `create-instance`**,不要跳过 `form-schema` 直接拼请求。
|
||||
- **如果用户明确给出 `processCode`,固定走 `form-schema` → 收集表单值 → `forecast-process` → 自选节点选人 → `create-instance`**,不要跳过 `form-schema` 直接拼请求。
|
||||
- **`form-schema` 返回的 `content` 不是创建 payload 的原样模板。** 它主要用于识别控件 `label`(即 name)、`id`、控件类型(componentName)和选项值范围;真正的 `formComponentValues` 中 `value` 结构以本文的控件值格式表为准。
|
||||
- **`forecast-process` 返回的自选节点必须在发起前让用户选人。** 若 `workflowActivityRuleVOs` 中有 `targetSelect: true` 的节点,必须提示用户选择处理人,并将结果通过 `targetSelectActioners` 传入 `create-instance`。
|
||||
- **所有人员类参数使用 userId。** 若用户给的是姓名,先用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 解析成 userId。**严禁把姓名直接写进** `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。
|
||||
- **创建实例前一次性汇总确认。** `create-instance` 是写操作,执行前一次性展示模板、表单值、流程预测结果和审批人/抄送人供用户确认。
|
||||
|
||||
#### 严禁行为
|
||||
|
||||
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
|
||||
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
|
||||
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
|
||||
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
|
||||
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 转成 userId。
|
||||
- **严禁在未得到用户确认前直接执行真实提单。**
|
||||
- **严禁猜测控件名称或选项值。** 必须从 `form-schema` 返回中提取。
|
||||
- **严禁跳过 `forecast-process` 中的自选节点选人。** 若预测返回 `targetSelect: true` 的节点,必须让用户选人后再发起。
|
||||
|
||||
#### 最小判断表
|
||||
|
||||
| 你手上有什么 | 下一步 |
|
||||
|---|---|
|
||||
| 只有口语需求,比如"帮我发起请假审批" | 先 `search-forms --query 请假` |
|
||||
| 已拿到 `processCode` | 直接 `form-schema --process-code <code>` |
|
||||
| 已拿到 Schema | 向用户展示控件列表,收集表单值 |
|
||||
| 已收集表单值 | `forecast-process` 预测流程走向 |
|
||||
| 预测返回有 `targetSelect: true` 节点 | 让用户为自选节点选人(`dws aisearch person --keyword "<姓名>" --dimension name --format json` 解析姓名) |
|
||||
| 预测完成,自选节点已选人 | 汇总确认后 `create-instance --yes` |
|
||||
| 用户明确说"不走模板流程,直接指定审批人" | 使用 `directAppointedApprovers`(高级模式) |
|
||||
|
||||
#### 工作流
|
||||
|
||||
```
|
||||
1. search-forms --query <关键词> → 拿到 processCode(若已有则跳过)
|
||||
2. form-schema --process-code <code> → 拿到控件列表、类型、选项值
|
||||
3. 检查 Schema 中是否有不支持的必填控件 → 若有则直接告知用户不支持发起
|
||||
4. 收集表单值 → 向用户展示控件列表,收集用户填写的表单值
|
||||
5. forecast-process → 根据表单值预测流程走向,识别自选节点
|
||||
6. 自选节点选人 → 若预测返回 targetSelect=true 的节点,让用户选人(用 dws aisearch person --keyword "<姓名>" --dimension name --format json 解析姓名)
|
||||
7. 汇总确认后 create-instance --yes → 展示完整信息(表单值 + 流程路径 + 审批人),用户确认后执行发起
|
||||
```
|
||||
|
||||
> **IMPORTANT:每次发起实例前都必须重新调用 `form-schema` 查询模板。** 即使用户之前查询过同一个 processCode,模板可能已被修改(控件增减、选项变更、必填属性调整等),不得复用旧的 Schema 结果。
|
||||
|
||||
#### 交互优化原则
|
||||
|
||||
> **核心目标:流程清晰,步骤有序,避免重复询问。**
|
||||
|
||||
1. **先查 Schema 再收集表单值(步骤 2→4):** `form-schema` 后向用户展示需要填写的控件列表,然后一次性收集全部表单值。不要在未拿到 Schema 前就问用户填什么。
|
||||
|
||||
2. **流程预测后再选自选审批人(步骤 5→6):** `forecast-process` 返回流程路径和自选节点后:
|
||||
- 先向用户展示完整的流程路径(经过哪些节点、各节点处理人)
|
||||
- 对 `targetSelect: true` 的节点,提示用户"节点「{activityName}」需要您自选{actorType}人"
|
||||
- 用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 帮用户查找并选人
|
||||
- 若有多个自选节点,一次性收集所有自选节点的选人结果
|
||||
|
||||
3. **单次汇总确认(步骤 7):** 发起前一次性展示完整信息供用户确认:
|
||||
- 审批模板名称
|
||||
- 表单各控件值
|
||||
- 流程预测结果(审批路径)
|
||||
- 各节点审批人/抄送人(含自选节点选人结果)
|
||||
|
||||
**反例(禁止):**
|
||||
- 未查 Schema 就直接问用户填什么表单值
|
||||
- 流程预测后逐个节点分别询问选人,而非一次性收集
|
||||
- 用户确认前直接执行发起
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws oa approval create-instance [flags]
|
||||
Example:
|
||||
# 简单发起(Agent 在汇总确认后需加 --yes)
|
||||
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试内容"}' --yes
|
||||
# 指定审批人(OR=或签,AND=会签,NONE=单人)
|
||||
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试"}' --approvers "userId1,userId2" --approvers-action-type OR --yes
|
||||
# 指定抄送人
|
||||
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试"}' --cc-list "userId1" --cc-position START --yes
|
||||
# 高级用法:传入完整 JSON(支持 directAppointedApprovers、targetSelectActioners 等全部字段)
|
||||
dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[{"name":"单行输入框","value":"测试"}]}' --yes
|
||||
Flags:
|
||||
--process-code string 审批模板 processCode(简单模式必填)
|
||||
--form-values string 表单值 JSON,格式 '{"控件名称":"值"}'(简单模式必填)
|
||||
--dept-id string 发起人所在部门 ID,根部门填 -1(可选,默认 -1)
|
||||
--originator-user-id string 审批发起人 userId(可选,MCP 工具可从登录态获取)
|
||||
--approvers string 审批人 userId 列表,多个用逗号分隔(可选)
|
||||
--approvers-action-type string 审批类型:AND(会签)、OR(或签)、NONE(单人)(可选,默认 OR)
|
||||
--cc-list string 抄送人 userId 列表,多个用逗号分隔(可选)
|
||||
--cc-position string 抄送时间点:START/FINISH/START_FINISH(可选,默认 START)
|
||||
--request string 完整请求体 JSON(高级模式,与简单模式互斥)
|
||||
--yes 显式确认并发起审批;未提供时命令直接拒绝,不进入交互确认(Agent 必须先汇总并获得用户确认)
|
||||
```
|
||||
|
||||
#### 两种模式
|
||||
|
||||
- **简单模式:** 通过 `--process-code` + `--form-values` + 可选 flags 发起,适合大多数场景
|
||||
- **高级模式:** 通过 `--request` 传入完整 JSON 请求体,支持 `directAppointedApprovers`、`targetSelectActioners` 等复杂字段
|
||||
|
||||
#### 组装 form-values
|
||||
|
||||
`form-values` 是简单模式下的核心入参;传入时必须是一个 JSON 对象字符串,key 为控件 label,value 为该控件的提交值。组装原则:
|
||||
|
||||
- 先用 `form-schema` 识别有哪些控件、每个控件的 `label`(name)、`componentName`(type)、选项值范围以及明细子控件结构。
|
||||
- **`form-schema` 返回的 `content` 不是可直接提交的原样模板。** 它提供控件定义,`value` 结构须按下方控件值格式表组装。
|
||||
- 提交时必须保证每个控件的 `name`(即 label)与 Schema 中的 `props.label` **完全一致**。
|
||||
- 如果用户提供的是人员信息,先用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 转成 userId 后再写入对应控件。
|
||||
- 单选/多选控件提交的是选项文本(option value),该值从 `form-schema` 返回的选项定义中取得。
|
||||
- `InnerContactField`、`DepartmentField`、`TableField`、`DDDateRangeField`、`DDAttachment` 等控件的 `value` 结构各不相同,必须按下方格式表单独组装,不要套用文本控件的写法。
|
||||
- `TextNote`(文字说明)不收集数据,**不要**出现在 `formComponentValues` 中。
|
||||
|
||||
#### 表单控件值格式速查
|
||||
|
||||
> **重要:** `formComponentValues` 中每条记录的 `name` 必须与审批模板中控件的 `label`(即 `form-schema` 返回的 `content.items[].props.label`)**完全一致**。`value` 为字符串类型,最大 65535 字符。
|
||||
>
|
||||
> **详细参考:** 每种控件的完整属性、约束和示例见 [oa-form-components.md](oa/oa-form-components.md)。组装前**必须先阅读该文档**。
|
||||
|
||||
| 控件类型 | componentName | value 格式 | 示例 | 备注 |
|
||||
|---------|---------------|-----------|------|-------------------------------------------------------|
|
||||
| 单行输入框 | `TextField` | 纯文本 | `"测试内容"` | |
|
||||
| 多行输入框 | `TextareaField` | 纯文本 | `"第一行\n第二行"` | |
|
||||
| 数字输入框 | `NumberField` | 数字字符串 | `"100"` | |
|
||||
| 单选框 | `DDSelectField` | 选项文本 | `"同意"` | 必须与模板 options 中的 value 完全匹配 |
|
||||
| 多选框 | `DDMultiSelectField` | JSON 数组字符串 | `'["选项A","选项B"]'` | 每个选项须与模板 options 匹配; |
|
||||
| 日期控件 | `DDDateField` | `yyyy-MM-dd` | `"2026-07-27"` | |
|
||||
| 时间区间 | `DDDateRangeField` | JSON 数组字符串 | `'["2026-07-27","2026-07-30"]'` | label 为数组 `["开始","结束"]`,用开始时间 label 作 name |
|
||||
| 金额控件 | `MoneyField` | 数字字符串 | `"1500.50"` | 自动显示大写金额 |
|
||||
| 电话控件 | `PhoneField` | 手机号字符串 | `"13800138000"` | |
|
||||
| 联系人控件 | `InnerContactField` | userId | `"user123"` | 多人时传 JSON 数组 `'["user1","user2"]'`;choice="0"单选/"1"多 |
|
||||
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
|
||||
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
|
||||
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
|
||||
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
|
||||
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
|
||||
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
|
||||
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
|
||||
| 身份证控件 | `IdCardField` | 身份证号 | `"330102199001011234"` | 内置格式校验 |
|
||||
| 文字说明 | `TextNote` | — | — | **不收集数据**,不会出现在 formComponentValues 中 |
|
||||
|
||||
#### API 不支持的控件
|
||||
|
||||
以下控件**不支持**通过创建实例 API 提交:
|
||||
|
||||
- `TextNote`(文字说明)— 纯展示,不收集数据
|
||||
- `CalculateField`(计算公式)— 由系统自动计算
|
||||
- `SeqNumberField`(流水号)— 由系统自动生成
|
||||
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
|
||||
- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
|
||||
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
|
||||
|
||||
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
|
||||
|
||||
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
|
||||
|
||||
> **必填不支持控件判断规则:** 检查 `form-schema` 返回的控件列表,若存在上述不支持控件且其 `props.required` 为 `true`(必填项),则**直接告知用户该审批模板不支持通过 CLI 发起**,请在钉钉客户端操作。只有不支持控件为非必填时,才可跳过该控件继续发起。
|
||||
|
||||
#### 高级模式请求体字段(`--request` JSON 完整结构)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| `processCode` | String | 是 | 审批模板唯一码 |
|
||||
| `originatorUserId` | String | 是 | 发起人 userId(MCP 工具可从登录态自动获取) |
|
||||
| `deptId` | Long | 否 | 发起人部门 ID,根部门填 -1;approvers 已传时可不填 |
|
||||
| `formComponentValues` | Array | 是 | 表单控件值列表,最大 150 条 |
|
||||
| `approvers` | Array | 否 | 指定审批人列表(覆盖模板流程),最大 20 条 |
|
||||
| `approvers[].actionType` | String | 否 | `AND`(会签)/ `OR`(或签)/ `NONE`(单人) |
|
||||
| `approvers[].userIds` | Array | 否 | 审批人 userId 列表 |
|
||||
| `ccList` | Array | 否 | 抄送人 userId 列表,最大 50 |
|
||||
| `ccPosition` | String | 否 | `START` / `FINISH` / `START_FINISH` |
|
||||
| `directAppointedApprovers` | Array | 否 | 指定审批人组(覆盖模板流程),结构见下方 |
|
||||
| `targetSelectActioners` | Array | 否 | 自选审批人(模板中有自选节点时必填),最大 20 条 |
|
||||
|
||||
#### 节点参数组装
|
||||
|
||||
> **详细参考:** 流程节点类型、审批模式、条件分支和 10 种审批人选择规则的完整说明见 [oa-process-nodes.md](oa/oa-process-nodes.md)。
|
||||
|
||||
**directAppointedApprovers(指定审批人覆盖模板流程):**
|
||||
|
||||
当用户明确说"不走模板默认流程"或"直接指定 XX 审批"时使用。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"staffIds": ["userId1", "userId2"],
|
||||
"taskActionType": "NONE",
|
||||
"staffId": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
- `staffIds`:审批人 userId 列表(必须通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取,严禁填姓名)
|
||||
- `taskActionType`:`NONE`(单人审批)/ `AND`(会签)/ `OR`(或签)
|
||||
|
||||
**targetSelectActioners(模板有自选审批节点时使用):**
|
||||
|
||||
当 `form-schema` 返回的模板流程中存在自选审批节点(`target_select` 类型)时必填。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"actionerKey": "manual_nodeId_xxxx_yyyy",
|
||||
"actionerStaffIds": ["userId1"]
|
||||
}
|
||||
]
|
||||
```
|
||||
- `actionerKey`:自选节点的规则 key,可通过获取审批单流程节点信息接口获取 `actorKey`
|
||||
- `actionerStaffIds`:操作人 userId 列表
|
||||
|
||||
**审批类型(approvers actionType)说明:**
|
||||
|
||||
| 值 | 含义 | 说明 |
|
||||
|----|------|------|
|
||||
| `AND` | 会签 | 所有审批人都必须审批通过 |
|
||||
| `OR` | 或签 | 任一审批人审批即可 |
|
||||
| `NONE` | 单人审批 | 只有一个审批人 |
|
||||
|
||||
**抄送时间点(ccPosition)说明:**
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `START` | 审批发起时抄送 |
|
||||
| `FINISH` | 审批完成时抄送 |
|
||||
| `START_FINISH` | 发起和完成时都抄送 |
|
||||
|
||||
#### 表单控件约束
|
||||
|
||||
- 单个表单最多 200 个控件
|
||||
- 控件 label(name)和 placeholder 最大 50 字符
|
||||
- `DDSelectField` / `DDMultiSelectField` 的选项 value 必须与模板中配置的选项文本完全一致
|
||||
- `TableField`(明细)内不可嵌套 `TableField`,不可包含 `DDMultiSelectField` 和 `DDPhotoField`
|
||||
- `TextNote`(文字说明)不收集数据,无需在 `formComponentValues` 中传入
|
||||
- `InnerContactField` 的 userId 应为当前组织下在职成员
|
||||
- `DepartmentField` 应传入当前组织下存在的部门 ID
|
||||
- `RelateField` 传入的审批实例 ID 应为当前组织下已存在的实例
|
||||
|
||||
#### 返回结果
|
||||
|
||||
创建成功后,返回的 `result` 字段即为新审批实例的 `processInstanceId`。建议向用户展示:
|
||||
|
||||
```
|
||||
审批已创建成功:
|
||||
|
||||
- 审批模板: <processName>(来自 form-schema)
|
||||
- 审批实例 ID: <processInstanceId>(来自 create-instance 返回的 result)
|
||||
```
|
||||
|
||||
后续可用该 processInstanceId 执行 `detail`、`tasks`、`records`、`revoke` 等操作。
|
||||
### 获取审批任务的被催办人 userId
|
||||
|
||||
> **催办必须两步串联:** ① `ding-info` 获取被催办人 `userId` → ② `ding message send` 发送催办消息。禁止跳过第一步直接猜测 userId。
|
||||
@@ -316,6 +673,18 @@ Flags:
|
||||
用户说"审批记录/操作历史" → `approval records`
|
||||
用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms / search-forms / detail 获取)
|
||||
用户说"有哪些审批表单/可见表单" → `approval list-forms`
|
||||
用户说"搜索审批表单/查找xx审批表单/有没有xx表单" → `approval search-forms`(需 --query)
|
||||
用户说"查表单schema/查表单结构/表单模板信息/查表单组件/查表单定义/表单有哪些字段/表单的字段信息" → `approval form-schema`(需 --process-code,可从 list-forms / search-forms / detail 获取)
|
||||
用户说"预测审批流程/流程预测/审批走向/这个审批走哪些人/审批流程预览" → `approval forecast-process`(需 --process-code、--dept-id、--form-values)
|
||||
- 在 `form-schema` 之后、`create-instance` 之前调用
|
||||
- 返回的 `workflowActivityRuleVOs` 中 `targetSelect: true` 的节点需要用户自选审批人
|
||||
- 自选结果组装为 `targetSelectActioners` 传入 `create-instance`
|
||||
用户说"发起审批/提交审批/帮我发起XX审批/新建审批单/提一个XX审批/帮我提XX申请" → 五步流程:① `search-forms --query XX` 获取 processCode → ② `form-schema --process-code <code>` 获取表单字段定义 → ③ 阅读 [oa-form-components.md](oa/oa-form-components.md) 和 [oa-process-nodes.md](oa/oa-process-nodes.md) 后组装表单值 → ④ `forecast-process` 预测流程走向并识别自选节点 → ⑤ 若有自选节点让用户选人,确认后 `create-instance --yes` 发起
|
||||
- 如果用户已知 processCode,可跳过第①步
|
||||
- `--form-values` 的 key 必须与 `form-schema` 返回的控件 label 一致
|
||||
- `forecast-process` 返回自选节点时必须让用户选人,不得跳过
|
||||
- 执行前**必须向用户确认**表单内容、流程预测结果、审批人和抄送人
|
||||
- 示例:"帮我发起一个AI审批单" → ① `search-forms --query AI` → ② `form-schema --process-code <code>` → ③ 组装表单值 → ④ `forecast-process` → ⑤ 向用户确认流程走向和自选审批人后 `create-instance --yes`
|
||||
用户说"我有哪些待审的任务" → `approval tasks`
|
||||
用户说"我发起的审批单" -> `approval list-submitted`
|
||||
用户说"我审批/处理过的审批单" -> `approval list-executed`
|
||||
@@ -351,6 +720,12 @@ dws oa approval records --instance-id <processInstanceId> --format json
|
||||
# 7. 获取可见审批表单(得到 processCode)
|
||||
dws oa approval list-forms --cursor 0 --limit 100 --format json
|
||||
|
||||
# 7b. 按关键字模糊搜索表单(快速定位 processCode)
|
||||
dws oa approval search-forms --query AI --format json
|
||||
|
||||
# 7c. 按 processCode 查询表单 Schema(获取表单结构、组件定义)
|
||||
dws oa approval form-schema --process-code <code> --format json
|
||||
|
||||
# 8. 查看自己发起的审批列表(--process-code 来自 list-forms / search-forms / detail)
|
||||
dws oa approval list-initiated --process-code <code> \
|
||||
--start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \
|
||||
@@ -373,6 +748,22 @@ dws oa approval oa-comments --instance-id <processInstanceId> --content "同意
|
||||
# 14. 对审批实例进行抄送(processInstanceId 来自 list-pending 或 detail)
|
||||
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "68674200835816" --format json
|
||||
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1,userId2" --format json
|
||||
|
||||
# 18. 发起审批(完整流程:搜表单 → 查 Schema → 收集表单值 → 流程预测 → 自选节点选人 → 发起)
|
||||
# 18a. 模糊搜索表单获取 processCode
|
||||
dws oa approval search-forms --query AI --format json
|
||||
# 18b. 查询表单 Schema 获取字段定义
|
||||
dws oa approval form-schema --process-code <code> --format json
|
||||
# 18c. 收集表单值(向用户展示控件列表,用户填写后组装 form-values)
|
||||
# 18d. 流程预测(根据表单值预测审批走向,识别自选审批人节点;processCode/deptId/formValues 必填,userId 由登录态自动填充)
|
||||
dws oa approval forecast-process --process-code <code> --dept-id -1 --form-values '{"单行输入框":"测试内容"}' --format json
|
||||
# 18e. 若 forecast 返回 targetSelect=true 的节点,用 dws aisearch person --keyword "<姓名>" --dimension name --format json 帮用户选人
|
||||
# 18f. 发起审批实例(form-values 的 key 须与 Schema 中控件 label 一致)
|
||||
dws oa approval create-instance --process-code <code> --form-values '{"单行输入框":"测试内容"}' --yes --format json
|
||||
# 18g. 发起并指定审批人和抄送人
|
||||
dws oa approval create-instance --process-code <code> --form-values '{"单行输入框":"测试"}' --approvers "userId1,userId2" --approvers-action-type OR --cc-list "userId3" --cc-position START --yes --format json
|
||||
# 18h. 发起并使用 forecast 自选审批人结果(高级模式)
|
||||
dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[{"name":"单行输入框","value":"测试"}],"targetSelectActioners":[{"actionerKey":"manual_33ff_89cb_da91_e3aa","actionerStaffIds":["userId_选人A"]}]}' --yes --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
@@ -384,6 +775,11 @@ dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1
|
||||
| `detail` | `processCode` | list-initiated 的 --process-code |
|
||||
| `list-forms` | `processCode` | list-initiated 的 --process-code |
|
||||
| `search-forms` | `processCode` | list-initiated 的 --process-code |
|
||||
| `form-schema` | `processCode`, `processName`, `content` | 查看表单结构定义;`content` 字段包含表单组件 JSON,可解析获取字段列表;**控件 label 作为 create-instance --form-values 的 key** |
|
||||
| `search-forms` → `form-schema` | `processCode` → 表单字段定义 | forecast-process / create-instance 的 --process-code 和 --form-values 填写依据 |
|
||||
| `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance |
|
||||
| `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners |
|
||||
| `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -395,6 +791,16 @@ dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1
|
||||
- `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳
|
||||
- `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询
|
||||
|
||||
- `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。
|
||||
- `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。
|
||||
- `create-instance` 发起前**应先调用 `forecast-process`** 预测流程走向,识别自选审批人节点(`targetSelect: true`),让用户选人后再提交。
|
||||
- `create-instance` 的 `--form-values` 接受 JSON 格式 `'{"控件名称":"值"}'`,代码会自动转为 `[{"name":"控件名称","value":"值"}]`。
|
||||
- `create-instance` 简单模式适合常见场景;如需 `directAppointedApprovers`(指定审批人覆盖模板流程)或 `targetSelectActioners`(自选审批节点)等高级字段,使用 `--request` 传完整 JSON。`--request` 与简单模式 flags 互斥。
|
||||
- `create-instance` 会创建真实审批数据;Agent 只有在用户确认模板、表单值、流程路径和人员后才能传入 `--yes`。
|
||||
- `create-instance` 返回的 processInstanceId 可用于 `detail`、`tasks`、`records`、`revoke` 等后续操作。
|
||||
- `forecast-process` 的 `processCode`、`deptId`、`formComponentValues` 三个字段均为必填(`userId` 由系统自动填充);`formComponentValues` 比 `create-instance` 多一层数组包裹(`[[{...}]]`),CLI 简单模式已自动处理。
|
||||
- `forecast-process` 返回 `workflowActivityRuleVOs` 中 `targetSelect: true` 的节点,其 `workflowActor.actorKey` 必须作为 `targetSelectActioners` 的 `actionerKey` 传入 `create-instance`。
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# OA 审批表单控件参考
|
||||
|
||||
本文档详细描述钉钉 OA 审批中每种表单控件(componentName)在**发起审批实例**时 `formComponentValues` 的 `value` 格式、约束和注意事项。
|
||||
|
||||
> **核心原则:** `formComponentValues[].name` 必须与审批模板中控件的 `props.label` **完全一致**,`value` 为字符串类型(最大 65535 字符)。
|
||||
|
||||
---
|
||||
|
||||
## 通用约束
|
||||
|
||||
| 约束 | 说明 |
|
||||
|------|------|
|
||||
| 单表单最大控件数 | 200 |
|
||||
| label / placeholder 最大长度 | 50 字符 |
|
||||
| value 最大长度 | 65535 字符 |
|
||||
| ID / bizAlias 唯一性 | 同一表单内不可重复 |
|
||||
| TextNote | 不收集数据,不出现在 formComponentValues 中 |
|
||||
|
||||
---
|
||||
|
||||
## 基础控件
|
||||
|
||||
### TextField(单行输入框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TextField` |
|
||||
| value 格式 | 纯文本字符串 |
|
||||
| 示例 | `"测试内容"` |
|
||||
| 约束 | 无特殊约束 |
|
||||
|
||||
```json
|
||||
{ "name": "单行输入框", "value": "测试内容" }
|
||||
```
|
||||
|
||||
### TextareaField(多行输入框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TextareaField` |
|
||||
| value 格式 | 纯文本字符串,支持换行 |
|
||||
| 示例 | `"第一行\n第二行"` |
|
||||
| 约束 | 无 `ratio` 属性 |
|
||||
|
||||
```json
|
||||
{ "name": "多行输入框", "value": "第一行\n第二行\n第三行" }
|
||||
```
|
||||
|
||||
### NumberField(数字输入框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `NumberField` |
|
||||
| value 格式 | 数字字符串 |
|
||||
| 示例 | `"100"` |
|
||||
| 约束 | 适合数量、天数等纯数字场景 |
|
||||
|
||||
```json
|
||||
{ "name": "加班天数", "value": "3" }
|
||||
```
|
||||
|
||||
### DDSelectField(单选框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDSelectField` |
|
||||
| value 格式 | 选项文本字符串 |
|
||||
| 示例 | `"同意"` |
|
||||
| 约束 | **必须与模板 `options[].value` 完全匹配**,不可自行编造选项 |
|
||||
|
||||
模板中的选项结构(从 `form-schema` 获取):
|
||||
```json
|
||||
"options": [
|
||||
{ "key": "option_0", "value": "同意" },
|
||||
{ "key": "option_1", "value": "不同意" }
|
||||
]
|
||||
```
|
||||
|
||||
提交时传选项的 `value` 文本:
|
||||
```json
|
||||
{ "name": "审批意见", "value": "同意" }
|
||||
```
|
||||
|
||||
### DDMultiSelectField(多选框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDMultiSelectField` |
|
||||
| value 格式 | JSON 数组字符串,每个元素为选项文本 |
|
||||
| 示例 | `'["选项A","选项B"]'` |
|
||||
| 约束 | 每个选项须与模板 `options[].value` 匹配;|
|
||||
|
||||
```json
|
||||
{ "name": "兴趣爱好", "value": "[\"阅读\",\"运动\"]" }
|
||||
```
|
||||
|
||||
### DDDateField(日期控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDDateField` |
|
||||
| value 格式 | `yyyy-MM-dd` 格式字符串 |
|
||||
| 示例 | `"2026-07-27"` |
|
||||
| 约束 | 格式固定,不可传其他日期格式 |
|
||||
|
||||
```json
|
||||
{ "name": "请假日期", "value": "2026-07-27" }
|
||||
```
|
||||
|
||||
### DDDateRangeField(时间区间控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDDateRangeField` |
|
||||
| value 格式 | JSON 数组字符串 `[开始日期, 结束日期]` |
|
||||
| 示例 | `'["2026-07-27","2026-07-30"]'` |
|
||||
| 约束 | `props.label` 为数组 `["开始时间","结束时间"]`;提交时 `name` 使用**开始时间的 label** |
|
||||
|
||||
模板中的 label 结构(从 `form-schema` 获取):
|
||||
```json
|
||||
"props": { "label": ["开始时间", "结束时间"] }
|
||||
```
|
||||
|
||||
提交时用**开始时间 label** 作为 name:
|
||||
```json
|
||||
{ "name": "开始时间", "value": "[\"2026-07-27\",\"2026-07-30\"]" }
|
||||
```
|
||||
|
||||
### PhoneField(电话控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `PhoneField` |
|
||||
| value 格式 | 手机号字符串 |
|
||||
| 示例 | `"13800138000"` |
|
||||
| 约束 | `mode: "phone"` 为手机号 |
|
||||
|
||||
```json
|
||||
{ "name": "联系电话", "value": "13800138000" }
|
||||
```
|
||||
|
||||
### IdCardField(身份证控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `IdCardField` |
|
||||
| value 格式 | 身份证号字符串 |
|
||||
| 示例 | `"330102199001011234"` |
|
||||
| 约束 | 内置格式校验,须传合法身份证号 |
|
||||
|
||||
```json
|
||||
{ "name": "身份证号", "value": "330102199001011234" }
|
||||
```
|
||||
|
||||
### TextNote(文字说明)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TextNote` |
|
||||
| value 格式 | — |
|
||||
| 约束 | **不收集数据**,不出现在 formComponentValues 中 |
|
||||
|
||||
> 遇到 TextNote 控件时直接跳过,不要尝试为它填写值。
|
||||
|
||||
---
|
||||
|
||||
## 增强控件
|
||||
|
||||
### MoneyField(金额控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `MoneyField` |
|
||||
| value 格式 | 数字字符串 |
|
||||
| 示例 | `"1500.50"` |
|
||||
| 约束 | 系统自动显示大写金额(`notUpper: "0"` 时显示) |
|
||||
|
||||
```json
|
||||
{ "name": "报销金额", "value": "1500.50" }
|
||||
```
|
||||
|
||||
### InnerContactField(联系人控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|----------------------------------------------------|
|
||||
| `componentName` | `InnerContactField` |
|
||||
| value 格式 | userId 字符串,多人时为 JSON 数组字符串 |
|
||||
| 示例(单选) | `"user123"` |
|
||||
| 示例(多选) | `'["userId1","userId2"]'` |
|
||||
| 约束 | `choice: "0"` 单选 / `"1"` 多选;userId 须为**当前组织下在职成员** |
|
||||
|
||||
```json
|
||||
{ "name": "项目负责人", "value": "[\"userId1\",\"userId2\"]" }
|
||||
```
|
||||
|
||||
> **严禁直接写姓名。** 必须先通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 查询获取 userId;多结果时须让用户消歧确认。
|
||||
|
||||
### DepartmentField(部门控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DepartmentField` |
|
||||
| value 格式 | 部门 ID 字符串,多部门时为 JSON 数组字符串 |
|
||||
| 示例(单选) | `"12345"` |
|
||||
| 示例(多选) | `'["12345","67890"]'` |
|
||||
| 约束 | `multiple: boolean` 控制单选/多选;部门 ID 须为**当前组织下存在的部门** |
|
||||
|
||||
```json
|
||||
{ "name": "所属部门", "value": "12345" }
|
||||
```
|
||||
|
||||
### AddressField(省市区控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `AddressField` |
|
||||
| value 格式 | JSON 数组字符串 `["省","市","区"]` |
|
||||
| 示例 | `'["浙江省","杭州市","西湖区"]'` |
|
||||
| 约束 | 三级联动选择器;`needDetail: true` 时末尾追加详细地址文本 |
|
||||
|
||||
```json
|
||||
{ "name": "办公地点", "value": "[\"浙江省\",\"杭州市\",\"西湖区\"]" }
|
||||
```
|
||||
|
||||
### DDPhotoField(图片控件)
|
||||
|
||||
> **支持通过图片 URL 提交,不支持本地文件上传。** 如果用户已有图片 URL(如公网可访问的图片链接),可直接填入 value 提交。CLI 尚未封装本地文件上传到钉盘 CDN 的流程,若用户只有本地文件而非 URL,需告知用户在钉钉客户端补充。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDPhotoField` |
|
||||
| value 格式 | URL 数组转义字符串,即使只有一个 URL 也需数组形式 |
|
||||
| 示例 | `"[\"http://example.com/img1.jpg\",\"http://example.com/img2.jpg\"]"` |
|
||||
| 约束 | 支持 URL 直接提交;**不支持本地文件上传**(CLI 未封装钉盘上传流程); |
|
||||
|
||||
```json
|
||||
{ "name": "图片", "value": "[\"http://example.com/photo.jpg\"]" }
|
||||
```
|
||||
|
||||
### DDAttachment(附件控件)
|
||||
|
||||
> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDAttachment` |
|
||||
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
|
||||
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
|
||||
| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
|
||||
|
||||
### StarRatingField(评分控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `StarRatingField` |
|
||||
| value 格式 | 数字字符串 |
|
||||
| 示例 | `"4"` |
|
||||
| 约束 | `limit` 控制最大星数(默认 5) |
|
||||
|
||||
```json
|
||||
{ "name": "满意度评分", "value": "4" }
|
||||
```
|
||||
|
||||
### RelateField(关联审批单)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `RelateField` |
|
||||
| value 格式 | 审批实例 ID 字符串 |
|
||||
| 示例 | `"q-ZZ1sQaTIuYFpKI9aNC1g"` |
|
||||
| 约束 | 须为**当前组织下已存在的审批实例 ID** |
|
||||
|
||||
```json
|
||||
{ "name": "关联审批单", "value": "q-ZZ1sQaTIuYFpKI9aNC1g" }
|
||||
```
|
||||
|
||||
### SignatureField(签名控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `SignatureField` |
|
||||
| value 格式 | 签名图片 mediaId |
|
||||
| 约束 | 需要客户端交互签名,通常不支持 API 直接提交 |
|
||||
|
||||
---
|
||||
|
||||
## 复合控件
|
||||
|
||||
### TableField(明细控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TableField` |
|
||||
| value 格式 | JSON 数组字符串,每个元素为一行数据的键值对 |
|
||||
| 示例 | `'[{"商品名":"笔记本","数量":"2"},{"商品名":"钢笔","数量":"1"}]'` |
|
||||
| 约束 | **不可嵌套 TableField**;**不可包含 DDMultiSelectField 和 DDPhotoField**;最大 100 行;总长度不超过 65535 字符 |
|
||||
|
||||
模板结构(从 `form-schema` 获取):
|
||||
```json
|
||||
{
|
||||
"componentName": "TableField",
|
||||
"props": { "label": "采购明细" },
|
||||
"children": [
|
||||
{ "componentName": "TextField", "props": { "label": "商品名", "id": "TextField_XXX" } },
|
||||
{ "componentName": "NumberField", "props": { "label": "数量", "id": "NumberField_YYY" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
提交时每行用子控件 label 作 key:
|
||||
```json
|
||||
{
|
||||
"name": "采购明细",
|
||||
"value": "[{\"商品名\":\"笔记本\",\"数量\":\"2\"},{\"商品名\":\"钢笔\",\"数量\":\"1\"}]"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 不支持的控件
|
||||
|
||||
以下控件**不支持**通过创建实例 API 提交,遇到时应告知用户需在钉钉客户端补充:
|
||||
|
||||
| 控件 | componentName | 原因 |
|
||||
|------|---------------|------|
|
||||
| 文字说明 | `TextNote` | 纯展示,不收集数据 |
|
||||
| 计算公式 | `CalculateField` | 由系统自动计算,不可手动填写 |
|
||||
| 流水号 | `SeqNumberField` | 由系统自动生成 |
|
||||
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
|
||||
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
|
||||
| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
|
||||
|
||||
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
|
||||
|
||||
> **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作。
|
||||
|
||||
---
|
||||
|
||||
## 组装优先级
|
||||
|
||||
1. **每次发起前都重新调用 `form-schema`**,不得复用旧结果(模板可能已被修改)
|
||||
2. 先读 `form-schema` 返回的 `content`,识别所有控件的 `label`、`componentName`、`options`、`props.required`
|
||||
3. **检查是否存在不支持控件且为必填项(`props.required: true`)**,若有则直接告知用户该模板不支持通过 CLI 发起,请在钉钉客户端操作
|
||||
4. 按本文档中每种控件的 value 格式组装 `formComponentValues`
|
||||
5. **不要把 `form-schema` 的 `content` 当成可直接提交的模板**
|
||||
6. 遇到 API 不支持的控件(非必填),跳过并告知用户
|
||||
@@ -0,0 +1,374 @@
|
||||
# OA 审批流程节点与审批人规则参考
|
||||
|
||||
本文档描述钉钉 OA 审批的流程节点类型、审批模式、条件分支和审批人选择规则,用于理解审批模板结构和正确填写 `create-instance` 的节点参数。
|
||||
|
||||
---
|
||||
|
||||
## 流程结构概览
|
||||
|
||||
审批流程是一个嵌套树结构:
|
||||
|
||||
- **根节点**:发起人节点(`type: "start"`,`nodeId: "sid-startevent"`),固定不可删除
|
||||
- **后续节点**:通过 `childNode` 链接形成链式结构
|
||||
- **分支节点**:条件分支(`route` + `condition`)或并行分支(`parallel`)
|
||||
- 当没有后续节点时,`childNode` 字段**必须省略**(不可设为 `null`)
|
||||
|
||||
---
|
||||
|
||||
## 7 种节点类型
|
||||
|
||||
### 1. 发起人节点(start)
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `start` |
|
||||
| `nodeId` | `sid-startevent`(固定) |
|
||||
| `properties` | `{}`(空对象) |
|
||||
|
||||
唯一、不可删除。是流程的起点。
|
||||
|
||||
### 2. 审批人节点(approver)
|
||||
|
||||
核心决策节点,有审批/拒绝权限。
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `actionerRules` | Array | 是 | 审批人选择规则,至少一条 |
|
||||
| `activateType` | String | 是 | 多人审批模式(见下方) |
|
||||
| `approvalType` | String | 是 | 固定 `"MANUAL"` |
|
||||
| `agreeAll` | Boolean | 是 | `true` 全部通过 / `false` 任一通过 |
|
||||
| `noneActionerAction` | String | 否 | 如 `"admin"`(找不到审批人时转管理员) |
|
||||
|
||||
支持全部 10 种 actionerRules 类型。
|
||||
|
||||
### 3. 办理人节点(handler)
|
||||
|
||||
执行工作,无审批决策权。
|
||||
|
||||
| 属性 | 类型 | 必填 |
|
||||
|------|------|------|
|
||||
| `actionerRules` | Array | 是 |
|
||||
| `activateType` | String | 是 |
|
||||
|
||||
支持 9 种 actionerRules(不支持 `target_matrix_approval`)。
|
||||
|
||||
### 4. 抄送人节点(notifier)
|
||||
|
||||
仅接收通知,无决策权。
|
||||
|
||||
| 属性 | 类型 | 必填 |
|
||||
|------|------|------|
|
||||
| `actionerRules` | Array | 是 |
|
||||
|
||||
支持多条 actionerRules 组合在一个节点中,实现同时抄送多类人员。
|
||||
|
||||
### 5. 条件分支(route + condition)
|
||||
|
||||
条件路由节点,包含多个条件分支。
|
||||
|
||||
**route 节点:**
|
||||
- `type: "route"`
|
||||
- `conditionNodes[]`:分支数组,按优先级排序,**默认分支必须在最后**
|
||||
- `properties: {}`
|
||||
|
||||
**condition 节点(conditionNodes 的每个元素):**
|
||||
- `type: "condition"`
|
||||
- `isdefault: true`:标记默认分支
|
||||
- `properties.conditions`:二维条件数组
|
||||
- 外层数组:多个条件组,**OR 关系**
|
||||
- 内层数组:多个条件对象,**AND 关系**
|
||||
- 默认分支:`[[]]`(一个空组)
|
||||
|
||||
### 6. 并行分支(parallel)
|
||||
|
||||
多个分支同时执行,全部完成后才继续。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `branches[]` | 分支数组 |
|
||||
| `branches[].name` | 分支名称 |
|
||||
| `branches[].childNode` | 该分支的第一个节点 |
|
||||
|
||||
### 7. 付款人节点(payer)
|
||||
|
||||
财务付款节点。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `actionerRules` | 审批人规则 |
|
||||
| `paymentConfig.amountField` | 金额控件 ID |
|
||||
| `paymentConfig.accountField` | 收款账户控件 ID |
|
||||
|
||||
---
|
||||
|
||||
## 多人审批模式
|
||||
|
||||
| 模式 | `activateType` | `agreeAll` | 说明 |
|
||||
|------|---------------|-----------|------|
|
||||
| 会签 | `"ALL"` | `true` | 所有审批人都必须审批通过 |
|
||||
| 或签 | `"ALL"` | `false` | 任一审批人审批即可 |
|
||||
| 依次审批 | `"ONE_BY_ONE"` | `true` | 按顺序逐级审批 |
|
||||
|
||||
---
|
||||
|
||||
## 10 种审批人选择规则(actionerRules)
|
||||
|
||||
### 1. 指定成员(target_approval)
|
||||
|
||||
明确指定具体人员。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_approval",
|
||||
"approvals": [
|
||||
{ "userName": "张三", "workNo": "manager123" }
|
||||
],
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `workNo` 必须通过 `dws aisearch person --keyword "<工号>" --dimension jobNumber --format json` 获取,**严禁编造**
|
||||
- 在 `create-instance` 中对应 `directAppointedApprovers` 的 `staffIds`
|
||||
|
||||
### 2. 直属主管(target_formula / reportLineManager)
|
||||
|
||||
按汇报线找到直属主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_formula",
|
||||
"subType": "reportLineManager",
|
||||
"formula": "ReportLineManager(corpId,originator,1)",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `formula` 中最后的数字 N 表示第 N 级主管
|
||||
- **重要区分:** 用户说"直属主管/直属领导/汇报线主管"才用此规则;用户说"主管审批/leader审批"(模糊)时默认用 `target_management`(部门主管)
|
||||
|
||||
### 3. 发起人自己(target_originator)
|
||||
|
||||
发起人自行审批。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_originator",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
最简单的规则,只有 `type` 和 `isEmpty`。
|
||||
|
||||
### 4. 部门主管(target_management)
|
||||
|
||||
从发起人所在部门层级找主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_management",
|
||||
"level": 1,
|
||||
"autoUp": true,
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `level: 1`:直接部门主管
|
||||
- `autoUp: true`:找不到时向上级部门搜索
|
||||
- **这是"主管审批/leader审批"模糊场景的默认选择**
|
||||
|
||||
### 5. 表单部门主管(target_formula / managerOfDept)
|
||||
|
||||
根据表单中部门控件选择的主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_formula",
|
||||
"subType": "managerOfDept",
|
||||
"formula": "ManagerOfDept(corpId,$('DepartmentField_XXX'),1)",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `formula` 中引用表单中的 `DepartmentField` 控件 ID
|
||||
|
||||
### 6. 发起人自选(target_select)
|
||||
|
||||
发起人在提单时自行选择审批人。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_select",
|
||||
"select": ["allStaff"],
|
||||
"range": {},
|
||||
"key": "manual_nodeId_xxxx_yyyy",
|
||||
"multi": 1,
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `select: ["allStaff"]`:可选全组织人员
|
||||
- `multi: 1`:单选
|
||||
- `key`:格式 `manual_{nodeId}_{hex}_{hex}`
|
||||
- 在 `create-instance` 中对应 `targetSelectActioners` 的 `actionerKey`
|
||||
|
||||
### 7. 角色标签主管(target_managers_labels)
|
||||
|
||||
按角色标签找多级主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_managers_labels",
|
||||
"labelNames": ["项目经理"],
|
||||
"labels": ["labelId123"],
|
||||
"levels": [1],
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `labels` 中的 ID 必须通过 `dws contact label get --names "<角色名>" --format json` 获取;已知角色名时直接查询,否则先 `dws contact label list --format json` 获取全部角色列表后匹配
|
||||
|
||||
### 8. 表单联系人(target_formcomponent_approval)
|
||||
|
||||
从表单中的联系人控件读取审批人。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_formcomponent_approval",
|
||||
"paramKey": "InnerContactField_XXX",
|
||||
"label": "项目负责人",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `paramKey` 指向表单中的 `InnerContactField` 控件 ID
|
||||
- 该控件中填写的人即为审批人
|
||||
|
||||
### 9. 角色标签(target_label)
|
||||
|
||||
按角色标签找人(如"财务"、"HR")。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_label",
|
||||
"labelNames": "财务",
|
||||
"labels": "459272424",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `labels`:角色标签 ID(字符串),必须通过 `dws contact label get --names "<角色名>" --format json` 获取;未知角色名时先 `dws contact label list --format json`
|
||||
- `labelNames`:角色显示名称
|
||||
- **严禁编造 label ID**
|
||||
|
||||
### 10. 审批矩阵(target_matrix_approval)
|
||||
|
||||
按审批矩阵规则确定审批人。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_matrix_approval",
|
||||
"matrixId": "xxx",
|
||||
"roleColumnId": "yyy",
|
||||
"expression": {
|
||||
"subFilters": [...],
|
||||
"operator": "AND"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 仅适用于审批人节点
|
||||
- 目前尚在完善中
|
||||
|
||||
---
|
||||
|
||||
## 条件分支详解
|
||||
|
||||
### 条件类型
|
||||
|
||||
| `type` | 依据 | 关键字段 |
|
||||
|--------|------|---------|
|
||||
| `dingtalk_actioner_dept_condition` | 发起人部门/人员/角色 | `paramKey: "dingtalk_origin_dept"`, `conds[]` |
|
||||
| `dingtalk_actioner_dept_component_condition` | 表单部门控件 | `paramKey: 控件ID`, `conds[]` |
|
||||
| `dingtalk_actioner_range_condition` | 数值/金额/时长范围 | `lowerBound`(>=) / `lowerBoundNotEqual`(>) / `upperBoundEqual`(<=) / `upperBound`(<) / `boundEqual`(=) |
|
||||
| `dingtalk_actioner_value_condition` | 单选匹配 | `paramKey: 控件ID`, `paramValues[]`(选项 key) |
|
||||
| `dingtalk_multi_value_condition` | 多选匹配 | `paramKey: 控件ID`, `paramValues[]`, `matchType`(1=精确/2=全选/3=任一) |
|
||||
| `dingtalk_actioner_cascade_component_condition` | 级联控件 | `paramValues[]`, `displayValues[]` |
|
||||
| `dingtalk_actioner_boolean_condition` | 布尔值 | `boundEqual: true/false` |
|
||||
| `dingtalk_rule_template` | 节假日判断 | `template`, `outVars` |
|
||||
| `dingtalk_formula` | 公式 | `formula`, `formulaDisplay` |
|
||||
| `dingtalk_biz_var_condition` | 业务变量 | `dsKey`, `conds[]` |
|
||||
| `dingtalk_table_condition` | 明细内字段 | `parentFieldId`, `componentName`, `paramValue` |
|
||||
|
||||
### 范围条件操作符
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `lowerBound` | >= (大于等于) |
|
||||
| `lowerBoundNotEqual` | > (大于) |
|
||||
| `upperBoundEqual` | <= (小于等于) |
|
||||
| `upperBound` | < (小于) |
|
||||
| `boundEqual` | = (等于) |
|
||||
|
||||
### 默认分支
|
||||
|
||||
- `isdefault: true`
|
||||
- `conditions: [[]]`(一个空的条件组)
|
||||
- **必须放在 `conditionNodes[]` 的最后**
|
||||
|
||||
---
|
||||
|
||||
## create-instance 中的节点参数映射
|
||||
|
||||
### directAppointedApprovers(指定审批人覆盖模板流程)
|
||||
|
||||
当需要**不使用模板默认流程、直接指定审批人**时使用。
|
||||
|
||||
```json
|
||||
{
|
||||
"directAppointedApprovers": [
|
||||
{
|
||||
"staffIds": ["userId1", "userId2"],
|
||||
"taskActionType": "NONE",
|
||||
"staffId": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `staffIds` | 审批人 userId 列表(通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取;多结果须消歧) |
|
||||
| `taskActionType` | `NONE`(单人)/ `AND`(会签)/ `OR`(或签) |
|
||||
| `staffId` | 留空字符串 |
|
||||
|
||||
### targetSelectActioners(自选审批人)
|
||||
|
||||
当模板流程中存在**自选审批节点**(`target_select` 类型)时必填。
|
||||
|
||||
```json
|
||||
{
|
||||
"targetSelectActioners": [
|
||||
{
|
||||
"actionerKey": "manual_nodeId_xxxx_yyyy",
|
||||
"actionerStaffIds": ["userId1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `actionerKey` | 自选节点的规则 key,从审批流程节点信息接口获取 `actorKey` |
|
||||
| `actionerStaffIds` | 操作人 userId 列表 |
|
||||
|
||||
---
|
||||
|
||||
## 组装优先级
|
||||
|
||||
1. 先用 `forecast-process` 获取模板的流程节点结构(`workflowActivityRuleVOs`)
|
||||
2. 根据节点中的 `activityType` 和 `targetSelect` 判断是否需要传入 `directAppointedApprovers` 或 `targetSelectActioners`
|
||||
3. 如果预测返回 `targetSelect: true` 的自选节点,`targetSelectActioners` 必填
|
||||
4. 如果用户要求覆盖默认流程,使用 `directAppointedApprovers`
|
||||
5. **所有 userId 必须通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取,严禁填姓名;多结果须消歧**
|
||||
|
||||
> **交互优化:** 若用户在 `forecast-process` 前已指定审批人/抄送人姓名,`forecast-process` 返回自选节点后应自动映射,仅对未覆盖的自选节点追问,不要重复询问。详见 [oa.md](../oa.md) 交互优化原则。
|
||||
@@ -120,6 +120,363 @@ Flags:
|
||||
--query string 关键字,匹配 processCode 或表单名称 (必填)
|
||||
```
|
||||
|
||||
### 按模板 processCode 查询表单 Schema 信息
|
||||
|
||||
> **说明:** 根据已知的 processCode 精确查询表单的完整 Schema,包括表单名称、状态、创建者、创建/修改时间以及表单组件 JSON(content 字段)。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws oa approval form-schema [flags]
|
||||
Example:
|
||||
dws oa approval form-schema --process-code PROC-594AE140-6AA5-4BA4-AF0C-9E6F66DB1E0B
|
||||
Flags:
|
||||
--process-code string 表单模板 processCode (必填)
|
||||
```
|
||||
|
||||
返回值字段:
|
||||
- `result.processName` — 表单名称
|
||||
- `result.processCode` — 表单 processCode
|
||||
- `result.processStatus` — 表单状态(如 `PUBLISHED`)
|
||||
- `result.creator` — 创建者 userId
|
||||
- `result.gmtCreate` / `result.gmtModified` — 创建/修改时间(毫秒时间戳)
|
||||
- `result.processIconUrl` — 表单图标 URL
|
||||
- `result.processDescription` — 表单描述
|
||||
- `result.content` — 表单组件 JSON 字符串,包含表单项(items)和标题等配置
|
||||
|
||||
### 流程预测
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws oa approval forecast-process [flags]
|
||||
Example:
|
||||
# 简单预测
|
||||
dws oa approval forecast-process --process-code PROC-xxx --dept-id -1 --form-values '{"单行输入框":"测试内容"}'
|
||||
# 指定部门预测
|
||||
dws oa approval forecast-process --process-code PROC-xxx --dept-id 12345 --form-values '{"金额":"5000"}'
|
||||
# 高级用法:传入完整 JSON
|
||||
dws oa approval forecast-process --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[[{"name":"单行输入框","value":"测试"}]]}'
|
||||
Flags:
|
||||
--process-code string 审批模板 processCode(简单模式必填)
|
||||
--form-values string 表单值 JSON,格式 '{"控件名称":"值"}'(简单模式必填)
|
||||
--dept-id string 发起人所在部门 ID,根部门填 -1(简单模式必填)
|
||||
--request string 完整请求体 JSON(高级模式,与简单模式互斥)
|
||||
```
|
||||
|
||||
> **注意:** forecast 接口的 `formComponentValues` 比 create-instance 多一层数组包裹(`[[{...}]]`),CLI 简单模式已自动处理,高级模式需自行包裹。`processCode`、`deptId`、`formComponentValues` 三个字段均为必填,`userId` 由系统从登录态自动填充。
|
||||
|
||||
#### 流程预测的作用
|
||||
|
||||
在 `create-instance` 之前调用 `forecast-process`,可以根据已填写的表单值预测审批流程走向,核心价值有两个:
|
||||
|
||||
1. **展示流程路径** — 告诉用户这个审批会经过哪些节点(审批人、抄送人、条件分支),让用户在提交前就知道流程走向。
|
||||
2. **识别自选审批人节点** — 返回中 `targetSelect: true` 的节点需要用户手动选择审批人/抄送人,Agent 应提示用户选人,并将结果传入 `create-instance` 的 `targetSelectActioners`。
|
||||
|
||||
#### 返回值关键字段
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `result.forecastSuccess` | 预测是否成功 |
|
||||
| `result.staticWorkflow` | 是否为静态流程(无条件分支) |
|
||||
| `result.workflowForecastNodes` | 流程节点路径,每个节点包含 `activityId` 和 `outIds`(下一跳) |
|
||||
| `result.workflowActivityRuleVOs` | **重点**:每个节点的详细规则,包含节点类型、审批人、是否自选等 |
|
||||
|
||||
#### `workflowActivityRuleVOs` 节点字段解读
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `activityId` | 节点 ID |
|
||||
| `workflowActor.actorKey` | 自选节点的规则 key,即 `targetSelectActioners` 中 `actionerKey` 的值 |
|
||||
| `activityName` | 节点名称(如"审批人"、"抄送人") |
|
||||
| `activityType` | 节点类型:`target_approval`(已指定审批人)、`target_select`(需自选)、`target_notifier`(抄送) |
|
||||
| `targetSelect` | **`true` 表示需要用户自选审批人/抄送人** |
|
||||
| `activityActioners` | 已确定的处理人列表(含 `emplId`、`name`) |
|
||||
| `workflowActor.actorType` | 角色类型:`approver`(审批人)、`notifier`(抄送人) |
|
||||
| `workflowActor.approvalMethod` | 多人审批方式:`ONE_BY_ONE`(依次审批) |
|
||||
| `workflowActor.actorSelectionType` | 选人范围:`allStaff`(全员可选)等 |
|
||||
| `prevActivityId` | 上一节点 ID |
|
||||
|
||||
#### Agent 处理流程
|
||||
|
||||
```
|
||||
1. 调用 forecast-process,传入 processCode + form-values
|
||||
2. 遍历 workflowActivityRuleVOs:
|
||||
a. 向用户展示每个节点的名称、类型、已指定处理人
|
||||
b. 若 targetSelect == true:
|
||||
- 提示用户"节点「{activityName}」需要您自选{actorType}人"
|
||||
- 使用 dws aisearch person --keyword "<姓名>" --dimension name --format json 帮用户查找并选人
|
||||
- 记录 activityId 和用户选择的 userIds
|
||||
3. 将自选结果组装为 targetSelectActioners,传入 create-instance 高级模式 --request
|
||||
```
|
||||
|
||||
#### 自选节点 → `targetSelectActioners` 组装示例
|
||||
|
||||
假设 forecast 返回两个自选节点:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetSelectActioners": [
|
||||
{
|
||||
"actionerKey": "manual_33ff_89cb_da91_e3aa",
|
||||
"actionerStaffIds": ["userId_选人A"]
|
||||
},
|
||||
{
|
||||
"actionerKey": "manual_a29e_9633_f8b7_7291",
|
||||
"actionerStaffIds": ["userId_选人B"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
此字段通过 `create-instance --request` 的高级模式传入。`actionerKey` 来自 forecast 返回的 `workflowActor.actorKey`。
|
||||
|
||||
### 发起审批实例
|
||||
|
||||
#### 执行摘要
|
||||
|
||||
- **如果用户未明确给出 `processCode`,必须固定走 `search-forms` → `form-schema` → 收集表单值 → `forecast-process` → 自选节点选人 → `create-instance`**,不要跳过 `form-schema` 直接拼请求。
|
||||
- **如果用户明确给出 `processCode`,固定走 `form-schema` → 收集表单值 → `forecast-process` → 自选节点选人 → `create-instance`**,不要跳过 `form-schema` 直接拼请求。
|
||||
- **`form-schema` 返回的 `content` 不是创建 payload 的原样模板。** 它主要用于识别控件 `label`(即 name)、`id`、控件类型(componentName)和选项值范围;真正的 `formComponentValues` 中 `value` 结构以本文的控件值格式表为准。
|
||||
- **`forecast-process` 返回的自选节点必须在发起前让用户选人。** 若 `workflowActivityRuleVOs` 中有 `targetSelect: true` 的节点,必须提示用户选择处理人,并将结果通过 `targetSelectActioners` 传入 `create-instance`。
|
||||
- **所有人员类参数使用 userId。** 若用户给的是姓名,先用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 解析成 userId。**严禁把姓名直接写进** `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。
|
||||
- **创建实例前一次性汇总确认。** `create-instance` 是写操作,执行前一次性展示模板、表单值、流程预测结果和审批人/抄送人供用户确认。
|
||||
|
||||
#### 严禁行为
|
||||
|
||||
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
|
||||
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
|
||||
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
|
||||
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
|
||||
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 转成 userId。
|
||||
- **严禁在未得到用户确认前直接执行真实提单。**
|
||||
- **严禁猜测控件名称或选项值。** 必须从 `form-schema` 返回中提取。
|
||||
- **严禁跳过 `forecast-process` 中的自选节点选人。** 若预测返回 `targetSelect: true` 的节点,必须让用户选人后再发起。
|
||||
|
||||
#### 最小判断表
|
||||
|
||||
| 你手上有什么 | 下一步 |
|
||||
|---|---|
|
||||
| 只有口语需求,比如"帮我发起请假审批" | 先 `search-forms --query 请假` |
|
||||
| 已拿到 `processCode` | 直接 `form-schema --process-code <code>` |
|
||||
| 已拿到 Schema | 向用户展示控件列表,收集表单值 |
|
||||
| 已收集表单值 | `forecast-process` 预测流程走向 |
|
||||
| 预测返回有 `targetSelect: true` 节点 | 让用户为自选节点选人(`dws aisearch person --keyword "<姓名>" --dimension name --format json` 解析姓名) |
|
||||
| 预测完成,自选节点已选人 | 汇总确认后 `create-instance --yes` |
|
||||
| 用户明确说"不走模板流程,直接指定审批人" | 使用 `directAppointedApprovers`(高级模式) |
|
||||
|
||||
#### 工作流
|
||||
|
||||
```
|
||||
1. search-forms --query <关键词> → 拿到 processCode(若已有则跳过)
|
||||
2. form-schema --process-code <code> → 拿到控件列表、类型、选项值
|
||||
3. 检查 Schema 中是否有不支持的必填控件 → 若有则直接告知用户不支持发起
|
||||
4. 收集表单值 → 向用户展示控件列表,收集用户填写的表单值
|
||||
5. forecast-process → 根据表单值预测流程走向,识别自选节点
|
||||
6. 自选节点选人 → 若预测返回 targetSelect=true 的节点,让用户选人(用 dws aisearch person --keyword "<姓名>" --dimension name --format json 解析姓名)
|
||||
7. 汇总确认后 create-instance --yes → 展示完整信息(表单值 + 流程路径 + 审批人),用户确认后执行发起
|
||||
```
|
||||
|
||||
> **IMPORTANT:每次发起实例前都必须重新调用 `form-schema` 查询模板。** 即使用户之前查询过同一个 processCode,模板可能已被修改(控件增减、选项变更、必填属性调整等),不得复用旧的 Schema 结果。
|
||||
|
||||
#### 交互优化原则
|
||||
|
||||
> **核心目标:流程清晰,步骤有序,避免重复询问。**
|
||||
|
||||
1. **先查 Schema 再收集表单值(步骤 2→4):** `form-schema` 后向用户展示需要填写的控件列表,然后一次性收集全部表单值。不要在未拿到 Schema 前就问用户填什么。
|
||||
|
||||
2. **流程预测后再选自选审批人(步骤 5→6):** `forecast-process` 返回流程路径和自选节点后:
|
||||
- 先向用户展示完整的流程路径(经过哪些节点、各节点处理人)
|
||||
- 对 `targetSelect: true` 的节点,提示用户"节点「{activityName}」需要您自选{actorType}人"
|
||||
- 用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 帮用户查找并选人
|
||||
- 若有多个自选节点,一次性收集所有自选节点的选人结果
|
||||
|
||||
3. **单次汇总确认(步骤 7):** 发起前一次性展示完整信息供用户确认:
|
||||
- 审批模板名称
|
||||
- 表单各控件值
|
||||
- 流程预测结果(审批路径)
|
||||
- 各节点审批人/抄送人(含自选节点选人结果)
|
||||
|
||||
**反例(禁止):**
|
||||
- 未查 Schema 就直接问用户填什么表单值
|
||||
- 流程预测后逐个节点分别询问选人,而非一次性收集
|
||||
- 用户确认前直接执行发起
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws oa approval create-instance [flags]
|
||||
Example:
|
||||
# 简单发起(Agent 在汇总确认后需加 --yes)
|
||||
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试内容"}' --yes
|
||||
# 指定审批人(OR=或签,AND=会签,NONE=单人)
|
||||
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试"}' --approvers "userId1,userId2" --approvers-action-type OR --yes
|
||||
# 指定抄送人
|
||||
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试"}' --cc-list "userId1" --cc-position START --yes
|
||||
# 高级用法:传入完整 JSON(支持 directAppointedApprovers、targetSelectActioners 等全部字段)
|
||||
dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[{"name":"单行输入框","value":"测试"}]}' --yes
|
||||
Flags:
|
||||
--process-code string 审批模板 processCode(简单模式必填)
|
||||
--form-values string 表单值 JSON,格式 '{"控件名称":"值"}'(简单模式必填)
|
||||
--dept-id string 发起人所在部门 ID,根部门填 -1(可选,默认 -1)
|
||||
--originator-user-id string 审批发起人 userId(可选,MCP 工具可从登录态获取)
|
||||
--approvers string 审批人 userId 列表,多个用逗号分隔(可选)
|
||||
--approvers-action-type string 审批类型:AND(会签)、OR(或签)、NONE(单人)(可选,默认 OR)
|
||||
--cc-list string 抄送人 userId 列表,多个用逗号分隔(可选)
|
||||
--cc-position string 抄送时间点:START/FINISH/START_FINISH(可选,默认 START)
|
||||
--request string 完整请求体 JSON(高级模式,与简单模式互斥)
|
||||
--yes 显式确认并发起审批;未提供时命令直接拒绝,不进入交互确认(Agent 必须先汇总并获得用户确认)
|
||||
```
|
||||
|
||||
#### 两种模式
|
||||
|
||||
- **简单模式:** 通过 `--process-code` + `--form-values` + 可选 flags 发起,适合大多数场景
|
||||
- **高级模式:** 通过 `--request` 传入完整 JSON 请求体,支持 `directAppointedApprovers`、`targetSelectActioners` 等复杂字段
|
||||
|
||||
#### 组装 form-values
|
||||
|
||||
`form-values` 是简单模式下的核心入参;传入时必须是一个 JSON 对象字符串,key 为控件 label,value 为该控件的提交值。组装原则:
|
||||
|
||||
- 先用 `form-schema` 识别有哪些控件、每个控件的 `label`(name)、`componentName`(type)、选项值范围以及明细子控件结构。
|
||||
- **`form-schema` 返回的 `content` 不是可直接提交的原样模板。** 它提供控件定义,`value` 结构须按下方控件值格式表组装。
|
||||
- 提交时必须保证每个控件的 `name`(即 label)与 Schema 中的 `props.label` **完全一致**。
|
||||
- 如果用户提供的是人员信息,先用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 转成 userId 后再写入对应控件。
|
||||
- 单选/多选控件提交的是选项文本(option value),该值从 `form-schema` 返回的选项定义中取得。
|
||||
- `InnerContactField`、`DepartmentField`、`TableField`、`DDDateRangeField`、`DDAttachment` 等控件的 `value` 结构各不相同,必须按下方格式表单独组装,不要套用文本控件的写法。
|
||||
- `TextNote`(文字说明)不收集数据,**不要**出现在 `formComponentValues` 中。
|
||||
|
||||
#### 表单控件值格式速查
|
||||
|
||||
> **重要:** `formComponentValues` 中每条记录的 `name` 必须与审批模板中控件的 `label`(即 `form-schema` 返回的 `content.items[].props.label`)**完全一致**。`value` 为字符串类型,最大 65535 字符。
|
||||
>
|
||||
> **详细参考:** 每种控件的完整属性、约束和示例见 [oa-form-components.md](oa/oa-form-components.md)。组装前**必须先阅读该文档**。
|
||||
|
||||
| 控件类型 | componentName | value 格式 | 示例 | 备注 |
|
||||
|---------|---------------|-----------|------|-------------------------------------------------------|
|
||||
| 单行输入框 | `TextField` | 纯文本 | `"测试内容"` | |
|
||||
| 多行输入框 | `TextareaField` | 纯文本 | `"第一行\n第二行"` | |
|
||||
| 数字输入框 | `NumberField` | 数字字符串 | `"100"` | |
|
||||
| 单选框 | `DDSelectField` | 选项文本 | `"同意"` | 必须与模板 options 中的 value 完全匹配 |
|
||||
| 多选框 | `DDMultiSelectField` | JSON 数组字符串 | `'["选项A","选项B"]'` | 每个选项须与模板 options 匹配; |
|
||||
| 日期控件 | `DDDateField` | `yyyy-MM-dd` | `"2026-07-27"` | |
|
||||
| 时间区间 | `DDDateRangeField` | JSON 数组字符串 | `'["2026-07-27","2026-07-30"]'` | label 为数组 `["开始","结束"]`,用开始时间 label 作 name |
|
||||
| 金额控件 | `MoneyField` | 数字字符串 | `"1500.50"` | 自动显示大写金额 |
|
||||
| 电话控件 | `PhoneField` | 手机号字符串 | `"13800138000"` | |
|
||||
| 联系人控件 | `InnerContactField` | userId | `"user123"` | 多人时传 JSON 数组 `'["user1","user2"]'`;choice="0"单选/"1"多 |
|
||||
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
|
||||
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
|
||||
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
|
||||
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
|
||||
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
|
||||
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
|
||||
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
|
||||
| 身份证控件 | `IdCardField` | 身份证号 | `"330102199001011234"` | 内置格式校验 |
|
||||
| 文字说明 | `TextNote` | — | — | **不收集数据**,不会出现在 formComponentValues 中 |
|
||||
|
||||
#### API 不支持的控件
|
||||
|
||||
以下控件**不支持**通过创建实例 API 提交:
|
||||
|
||||
- `TextNote`(文字说明)— 纯展示,不收集数据
|
||||
- `CalculateField`(计算公式)— 由系统自动计算
|
||||
- `SeqNumberField`(流水号)— 由系统自动生成
|
||||
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
|
||||
- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
|
||||
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
|
||||
|
||||
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
|
||||
|
||||
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
|
||||
|
||||
> **必填不支持控件判断规则:** 检查 `form-schema` 返回的控件列表,若存在上述不支持控件且其 `props.required` 为 `true`(必填项),则**直接告知用户该审批模板不支持通过 CLI 发起**,请在钉钉客户端操作。只有不支持控件为非必填时,才可跳过该控件继续发起。
|
||||
|
||||
#### 高级模式请求体字段(`--request` JSON 完整结构)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| `processCode` | String | 是 | 审批模板唯一码 |
|
||||
| `originatorUserId` | String | 是 | 发起人 userId(MCP 工具可从登录态自动获取) |
|
||||
| `deptId` | Long | 否 | 发起人部门 ID,根部门填 -1;approvers 已传时可不填 |
|
||||
| `formComponentValues` | Array | 是 | 表单控件值列表,最大 150 条 |
|
||||
| `approvers` | Array | 否 | 指定审批人列表(覆盖模板流程),最大 20 条 |
|
||||
| `approvers[].actionType` | String | 否 | `AND`(会签)/ `OR`(或签)/ `NONE`(单人) |
|
||||
| `approvers[].userIds` | Array | 否 | 审批人 userId 列表 |
|
||||
| `ccList` | Array | 否 | 抄送人 userId 列表,最大 50 |
|
||||
| `ccPosition` | String | 否 | `START` / `FINISH` / `START_FINISH` |
|
||||
| `directAppointedApprovers` | Array | 否 | 指定审批人组(覆盖模板流程),结构见下方 |
|
||||
| `targetSelectActioners` | Array | 否 | 自选审批人(模板中有自选节点时必填),最大 20 条 |
|
||||
|
||||
#### 节点参数组装
|
||||
|
||||
> **详细参考:** 流程节点类型、审批模式、条件分支和 10 种审批人选择规则的完整说明见 [oa-process-nodes.md](oa/oa-process-nodes.md)。
|
||||
|
||||
**directAppointedApprovers(指定审批人覆盖模板流程):**
|
||||
|
||||
当用户明确说"不走模板默认流程"或"直接指定 XX 审批"时使用。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"staffIds": ["userId1", "userId2"],
|
||||
"taskActionType": "NONE",
|
||||
"staffId": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
- `staffIds`:审批人 userId 列表(必须通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取,严禁填姓名)
|
||||
- `taskActionType`:`NONE`(单人审批)/ `AND`(会签)/ `OR`(或签)
|
||||
|
||||
**targetSelectActioners(模板有自选审批节点时使用):**
|
||||
|
||||
当 `form-schema` 返回的模板流程中存在自选审批节点(`target_select` 类型)时必填。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"actionerKey": "manual_nodeId_xxxx_yyyy",
|
||||
"actionerStaffIds": ["userId1"]
|
||||
}
|
||||
]
|
||||
```
|
||||
- `actionerKey`:自选节点的规则 key,可通过获取审批单流程节点信息接口获取 `actorKey`
|
||||
- `actionerStaffIds`:操作人 userId 列表
|
||||
|
||||
**审批类型(approvers actionType)说明:**
|
||||
|
||||
| 值 | 含义 | 说明 |
|
||||
|----|------|------|
|
||||
| `AND` | 会签 | 所有审批人都必须审批通过 |
|
||||
| `OR` | 或签 | 任一审批人审批即可 |
|
||||
| `NONE` | 单人审批 | 只有一个审批人 |
|
||||
|
||||
**抄送时间点(ccPosition)说明:**
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `START` | 审批发起时抄送 |
|
||||
| `FINISH` | 审批完成时抄送 |
|
||||
| `START_FINISH` | 发起和完成时都抄送 |
|
||||
|
||||
#### 表单控件约束
|
||||
|
||||
- 单个表单最多 200 个控件
|
||||
- 控件 label(name)和 placeholder 最大 50 字符
|
||||
- `DDSelectField` / `DDMultiSelectField` 的选项 value 必须与模板中配置的选项文本完全一致
|
||||
- `TableField`(明细)内不可嵌套 `TableField`,不可包含 `DDMultiSelectField` 和 `DDPhotoField`
|
||||
- `TextNote`(文字说明)不收集数据,无需在 `formComponentValues` 中传入
|
||||
- `InnerContactField` 的 userId 应为当前组织下在职成员
|
||||
- `DepartmentField` 应传入当前组织下存在的部门 ID
|
||||
- `RelateField` 传入的审批实例 ID 应为当前组织下已存在的实例
|
||||
|
||||
#### 返回结果
|
||||
|
||||
创建成功后,返回的 `result` 字段即为新审批实例的 `processInstanceId`。建议向用户展示:
|
||||
|
||||
```
|
||||
审批已创建成功:
|
||||
|
||||
- 审批模板: <processName>(来自 form-schema)
|
||||
- 审批实例 ID: <processInstanceId>(来自 create-instance 返回的 result)
|
||||
```
|
||||
|
||||
后续可用该 processInstanceId 执行 `detail`、`tasks`、`records`、`revoke` 等操作。
|
||||
### 获取审批任务的被催办人 userId
|
||||
|
||||
> **催办必须两步串联:** ① `ding-info` 获取被催办人 `userId` → ② `ding message send` 发送催办消息。禁止跳过第一步直接猜测 userId。
|
||||
@@ -320,6 +677,17 @@ Flags:
|
||||
用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms 或 detail 获取)
|
||||
用户说"有哪些审批表单/可见表单" → `approval list-forms`
|
||||
用户说"搜索审批表单/查找xx审批表单/有没有xx表单" → `approval search-forms`(需 --query)
|
||||
用户说"查表单schema/查表单结构/表单模板信息/查表单组件/查表单定义/表单有哪些字段/表单的字段信息" → `approval form-schema`(需 --process-code,可从 list-forms / search-forms / detail 获取)
|
||||
用户说"预测审批流程/流程预测/审批走向/这个审批走哪些人/审批流程预览" → `approval forecast-process`(需 --process-code、--dept-id、--form-values)
|
||||
- 在 `form-schema` 之后、`create-instance` 之前调用
|
||||
- 返回的 `workflowActivityRuleVOs` 中 `targetSelect: true` 的节点需要用户自选审批人
|
||||
- 自选结果组装为 `targetSelectActioners` 传入 `create-instance`
|
||||
用户说"发起审批/提交审批/帮我发起XX审批/新建审批单/提一个XX审批/帮我提XX申请" → 五步流程:① `search-forms --query XX` 获取 processCode → ② `form-schema --process-code <code>` 获取表单字段定义 → ③ 阅读 [oa-form-components.md](oa/oa-form-components.md) 和 [oa-process-nodes.md](oa/oa-process-nodes.md) 后组装表单值 → ④ `forecast-process` 预测流程走向并识别自选节点 → ⑤ 若有自选节点让用户选人,确认后 `create-instance --yes` 发起
|
||||
- 如果用户已知 processCode,可跳过第①步
|
||||
- `--form-values` 的 key 必须与 `form-schema` 返回的控件 label 一致
|
||||
- `forecast-process` 返回自选节点时必须让用户选人,不得跳过
|
||||
- 执行前**必须向用户确认**表单内容、流程预测结果、审批人和抄送人
|
||||
- 示例:"帮我发起一个AI审批单" → ① `search-forms --query AI` → ② `form-schema --process-code <code>` → ③ 组装表单值 → ④ `forecast-process` → ⑤ 向用户确认流程走向和自选审批人后 `create-instance --yes`
|
||||
用户说"催办审批/DING 一下审批人/提醒审批/催一下审批/催批/提醒审批人" → 先 `approval ding-info`(拿到被催办人 `userId`),再 `ding message send`(将 userId 作为 `--users` 传入;`--robot-code` 优先走 `$DINGTALK_DING_ROBOT_CODE` 或向用户确认;`--content` 由 agent 根据审批上下文撰写)
|
||||
- **禁止跳过 ding-info:** 不得自行猜测或编造 userId,必须先调用 `ding-info` 获取
|
||||
- **机器人编码获取顺序:** ① `$DINGTALK_DING_ROBOT_CODE` 环境变量 → ② 用户显式提供 → ③ 询问用户
|
||||
@@ -378,6 +746,9 @@ dws oa approval list-forms --cursor 0 --limit 100 --format json
|
||||
# 7b. 按关键字模糊搜索表单(快速定位 processCode)
|
||||
dws oa approval search-forms --query AI --format json
|
||||
|
||||
# 7c. 按 processCode 查询表单 Schema(获取表单结构、组件定义)
|
||||
dws oa approval form-schema --process-code <code> --format json
|
||||
|
||||
# 8. 查看自己发起的审批列表(--process-code 来自 list-forms 或 detail)
|
||||
dws oa approval list-initiated --process-code <code> \
|
||||
--start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \
|
||||
@@ -420,6 +791,22 @@ dws oa approval revert-activities --task-id <taskId> --format json
|
||||
dws oa approval revert-task --instance-id <processInstanceId> --task-id <taskId> --target-activity-id sid-startevent --action REVERT_FOR_RESUBMIT --remark "补充说明后重提" --format json
|
||||
# 17c. 退回到某个审批节点重新审批(targetActivityId 和 action 从 revert-activities 返回中获取)
|
||||
dws oa approval revert-task --instance-id <processInstanceId> --task-id <taskId> --target-activity-id <activityId> --action REVERT_FOR_APPROVAL --remark "重新审批" --format json
|
||||
|
||||
# 18. 发起审批(完整流程:搜表单 → 查 Schema → 收集表单值 → 流程预测 → 自选节点选人 → 发起)
|
||||
# 18a. 模糊搜索表单获取 processCode
|
||||
dws oa approval search-forms --query AI --format json
|
||||
# 18b. 查询表单 Schema 获取字段定义
|
||||
dws oa approval form-schema --process-code <code> --format json
|
||||
# 18c. 收集表单值(向用户展示控件列表,用户填写后组装 form-values)
|
||||
# 18d. 流程预测(根据表单值预测审批走向,识别自选审批人节点;processCode/deptId/formValues 必填,userId 由登录态自动填充)
|
||||
dws oa approval forecast-process --process-code <code> --dept-id -1 --form-values '{"单行输入框":"测试内容"}' --format json
|
||||
# 18e. 若 forecast 返回 targetSelect=true 的节点,用 dws aisearch person --keyword "<姓名>" --dimension name --format json 帮用户选人
|
||||
# 18f. 发起审批实例(form-values 的 key 须与 Schema 中控件 label 一致)
|
||||
dws oa approval create-instance --process-code <code> --form-values '{"单行输入框":"测试内容"}' --yes --format json
|
||||
# 18g. 发起并指定审批人和抄送人
|
||||
dws oa approval create-instance --process-code <code> --form-values '{"单行输入框":"测试"}' --approvers "userId1,userId2" --approvers-action-type OR --cc-list "userId3" --cc-position START --yes --format json
|
||||
# 18h. 发起并使用 forecast 自选审批人结果(高级模式)
|
||||
dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[{"name":"单行输入框","value":"测试"}],"targetSelectActioners":[{"actionerKey":"manual_33ff_89cb_da91_e3aa","actionerStaffIds":["userId_选人A"]}]}' --yes --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
@@ -431,6 +818,11 @@ dws oa approval revert-task --instance-id <processInstanceId> --task-id <taskId>
|
||||
| `detail` | `processCode` | list-initiated 的 --process-code |
|
||||
| `list-forms` | `processCode` | list-initiated 的 --process-code |
|
||||
| `search-forms` | `processCode` | list-initiated 的 --process-code |
|
||||
| `form-schema` | `processCode`, `processName`, `content` | 查看表单结构定义;`content` 字段包含表单组件 JSON,可解析获取字段列表;**控件 label 作为 create-instance --form-values 的 key** |
|
||||
| `search-forms` → `form-schema` | `processCode` → 表单字段定义 | forecast-process / create-instance 的 --process-code 和 --form-values 填写依据 |
|
||||
| `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance |
|
||||
| `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners |
|
||||
| `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 |
|
||||
| `ding-info` | `userId` | ding message send 的 --users(多个逗号拼接);**robotCode 优先走 `$DINGTALK_DING_ROBOT_CODE` 环境变量,content 由 agent 根据审批上下文撰写;返回空时报错并停止** |
|
||||
| `revert-activities` | `activityId`, `revertAction`, `activityName` | revert-task 的 --target-activity-id 和 --action;**返回空时必须告知用户"无可回退节点"** |
|
||||
|
||||
@@ -459,6 +851,16 @@ dws oa approval revert-task --instance-id <processInstanceId> --task-id <taskId>
|
||||
- `ding-info` 返回空或报错时,必须明确告知用户"无法获取该任务的被催办人信息"并停止
|
||||
- DING 默认发应用内提醒(无成本);如需短信/电话提醒可加 `--type sms` 或 `--type call`(有成本,建议向用户确认)
|
||||
|
||||
- `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。
|
||||
- `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。
|
||||
- `create-instance` 发起前**应先调用 `forecast-process`** 预测流程走向,识别自选审批人节点(`targetSelect: true`),让用户选人后再提交。
|
||||
- `create-instance` 的 `--form-values` 接受 JSON 格式 `'{"控件名称":"值"}'`,代码会自动转为 `[{"name":"控件名称","value":"值"}]`。
|
||||
- `create-instance` 简单模式适合常见场景;如需 `directAppointedApprovers`(指定审批人覆盖模板流程)或 `targetSelectActioners`(自选审批节点)等高级字段,使用 `--request` 传完整 JSON。`--request` 与简单模式 flags 互斥。
|
||||
- `create-instance` 会创建真实审批数据;Agent 只有在用户确认模板、表单值、流程路径和人员后才能传入 `--yes`。
|
||||
- `create-instance` 返回的 processInstanceId 可用于 `detail`、`tasks`、`records`、`revoke` 等后续操作。
|
||||
- `forecast-process` 的 `processCode`、`deptId`、`formComponentValues` 三个字段均为必填(`userId` 由系统自动填充);`formComponentValues` 比 `create-instance` 多一层数组包裹(`[[{...}]]`),CLI 简单模式已自动处理。
|
||||
- `forecast-process` 返回 `workflowActivityRuleVOs` 中 `targetSelect: true` 的节点,其 `workflowActor.actorKey` 必须作为 `targetSelectActioners` 的 `actionerKey` 传入 `create-instance`。
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# OA 审批表单控件参考
|
||||
|
||||
本文档详细描述钉钉 OA 审批中每种表单控件(componentName)在**发起审批实例**时 `formComponentValues` 的 `value` 格式、约束和注意事项。
|
||||
|
||||
> **核心原则:** `formComponentValues[].name` 必须与审批模板中控件的 `props.label` **完全一致**,`value` 为字符串类型(最大 65535 字符)。
|
||||
|
||||
---
|
||||
|
||||
## 通用约束
|
||||
|
||||
| 约束 | 说明 |
|
||||
|------|------|
|
||||
| 单表单最大控件数 | 200 |
|
||||
| label / placeholder 最大长度 | 50 字符 |
|
||||
| value 最大长度 | 65535 字符 |
|
||||
| ID / bizAlias 唯一性 | 同一表单内不可重复 |
|
||||
| TextNote | 不收集数据,不出现在 formComponentValues 中 |
|
||||
|
||||
---
|
||||
|
||||
## 基础控件
|
||||
|
||||
### TextField(单行输入框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TextField` |
|
||||
| value 格式 | 纯文本字符串 |
|
||||
| 示例 | `"测试内容"` |
|
||||
| 约束 | 无特殊约束 |
|
||||
|
||||
```json
|
||||
{ "name": "单行输入框", "value": "测试内容" }
|
||||
```
|
||||
|
||||
### TextareaField(多行输入框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TextareaField` |
|
||||
| value 格式 | 纯文本字符串,支持换行 |
|
||||
| 示例 | `"第一行\n第二行"` |
|
||||
| 约束 | 无 `ratio` 属性 |
|
||||
|
||||
```json
|
||||
{ "name": "多行输入框", "value": "第一行\n第二行\n第三行" }
|
||||
```
|
||||
|
||||
### NumberField(数字输入框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `NumberField` |
|
||||
| value 格式 | 数字字符串 |
|
||||
| 示例 | `"100"` |
|
||||
| 约束 | 适合数量、天数等纯数字场景 |
|
||||
|
||||
```json
|
||||
{ "name": "加班天数", "value": "3" }
|
||||
```
|
||||
|
||||
### DDSelectField(单选框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDSelectField` |
|
||||
| value 格式 | 选项文本字符串 |
|
||||
| 示例 | `"同意"` |
|
||||
| 约束 | **必须与模板 `options[].value` 完全匹配**,不可自行编造选项 |
|
||||
|
||||
模板中的选项结构(从 `form-schema` 获取):
|
||||
```json
|
||||
"options": [
|
||||
{ "key": "option_0", "value": "同意" },
|
||||
{ "key": "option_1", "value": "不同意" }
|
||||
]
|
||||
```
|
||||
|
||||
提交时传选项的 `value` 文本:
|
||||
```json
|
||||
{ "name": "审批意见", "value": "同意" }
|
||||
```
|
||||
|
||||
### DDMultiSelectField(多选框)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDMultiSelectField` |
|
||||
| value 格式 | JSON 数组字符串,每个元素为选项文本 |
|
||||
| 示例 | `'["选项A","选项B"]'` |
|
||||
| 约束 | 每个选项须与模板 `options[].value` 匹配; |
|
||||
|
||||
```json
|
||||
{ "name": "兴趣爱好", "value": "[\"阅读\",\"运动\"]" }
|
||||
```
|
||||
|
||||
### DDDateField(日期控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDDateField` |
|
||||
| value 格式 | `yyyy-MM-dd` 格式字符串 |
|
||||
| 示例 | `"2026-07-27"` |
|
||||
| 约束 | 格式固定,不可传其他日期格式 |
|
||||
|
||||
```json
|
||||
{ "name": "请假日期", "value": "2026-07-27" }
|
||||
```
|
||||
|
||||
### DDDateRangeField(时间区间控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDDateRangeField` |
|
||||
| value 格式 | JSON 数组字符串 `[开始日期, 结束日期]` |
|
||||
| 示例 | `'["2026-07-27","2026-07-30"]'` |
|
||||
| 约束 | `props.label` 为数组 `["开始时间","结束时间"]`;提交时 `name` 使用**开始时间的 label** |
|
||||
|
||||
模板中的 label 结构(从 `form-schema` 获取):
|
||||
```json
|
||||
"props": { "label": ["开始时间", "结束时间"] }
|
||||
```
|
||||
|
||||
提交时用**开始时间 label** 作为 name:
|
||||
```json
|
||||
{ "name": "开始时间", "value": "[\"2026-07-27\",\"2026-07-30\"]" }
|
||||
```
|
||||
|
||||
### PhoneField(电话控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `PhoneField` |
|
||||
| value 格式 | 手机号字符串 |
|
||||
| 示例 | `"13800138000"` |
|
||||
| 约束 | `mode: "phone"` 为手机号 |
|
||||
|
||||
```json
|
||||
{ "name": "联系电话", "value": "13800138000" }
|
||||
```
|
||||
|
||||
### IdCardField(身份证控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `IdCardField` |
|
||||
| value 格式 | 身份证号字符串 |
|
||||
| 示例 | `"330102199001011234"` |
|
||||
| 约束 | 内置格式校验,须传合法身份证号 |
|
||||
|
||||
```json
|
||||
{ "name": "身份证号", "value": "330102199001011234" }
|
||||
```
|
||||
|
||||
### TextNote(文字说明)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TextNote` |
|
||||
| value 格式 | — |
|
||||
| 约束 | **不收集数据**,不出现在 formComponentValues 中 |
|
||||
|
||||
> 遇到 TextNote 控件时直接跳过,不要尝试为它填写值。
|
||||
|
||||
---
|
||||
|
||||
## 增强控件
|
||||
|
||||
### MoneyField(金额控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `MoneyField` |
|
||||
| value 格式 | 数字字符串 |
|
||||
| 示例 | `"1500.50"` |
|
||||
| 约束 | 系统自动显示大写金额(`notUpper: "0"` 时显示) |
|
||||
|
||||
```json
|
||||
{ "name": "报销金额", "value": "1500.50" }
|
||||
```
|
||||
|
||||
### InnerContactField(联系人控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|----------------------------------------------------|
|
||||
| `componentName` | `InnerContactField` |
|
||||
| value 格式 | userId 字符串,多人时为 JSON 数组字符串 |
|
||||
| 示例(单选) | `"user123"` |
|
||||
| 示例(多选) | `'["userId1","userId2"]'` |
|
||||
| 约束 | `choice: "0"` 单选 / `"1"` 多选;userId 须为**当前组织下在职成员** |
|
||||
|
||||
```json
|
||||
{ "name": "项目负责人", "value": "[\"userId1\",\"userId2\"]" }
|
||||
```
|
||||
|
||||
> **严禁直接写姓名。** 必须先通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 查询获取 userId;多结果时须让用户消歧确认。
|
||||
|
||||
### DepartmentField(部门控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DepartmentField` |
|
||||
| value 格式 | 部门 ID 字符串,多部门时为 JSON 数组字符串 |
|
||||
| 示例(单选) | `"12345"` |
|
||||
| 示例(多选) | `'["12345","67890"]'` |
|
||||
| 约束 | `multiple: boolean` 控制单选/多选;部门 ID 须为**当前组织下存在的部门** |
|
||||
|
||||
```json
|
||||
{ "name": "所属部门", "value": "12345" }
|
||||
```
|
||||
|
||||
### AddressField(省市区控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `AddressField` |
|
||||
| value 格式 | JSON 数组字符串 `["省","市","区"]` |
|
||||
| 示例 | `'["浙江省","杭州市","西湖区"]'` |
|
||||
| 约束 | 三级联动选择器;`needDetail: true` 时末尾追加详细地址文本 |
|
||||
|
||||
```json
|
||||
{ "name": "办公地点", "value": "[\"浙江省\",\"杭州市\",\"西湖区\"]" }
|
||||
```
|
||||
|
||||
### DDPhotoField(图片控件)
|
||||
|
||||
> **支持通过图片 URL 提交,不支持本地文件上传。** 如果用户已有图片 URL(如公网可访问的图片链接),可直接填入 value 提交。CLI 尚未封装本地文件上传到钉盘 CDN 的流程,若用户只有本地文件而非 URL,需告知用户在钉钉客户端补充。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDPhotoField` |
|
||||
| value 格式 | URL 数组转义字符串,即使只有一个 URL 也需数组形式 |
|
||||
| 示例 | `"[\"http://example.com/img1.jpg\",\"http://example.com/img2.jpg\"]"` |
|
||||
| 约束 | 支持 URL 直接提交;**不支持本地文件上传**(CLI 未封装钉盘上传流程); |
|
||||
|
||||
```json
|
||||
{ "name": "图片", "value": "[\"http://example.com/photo.jpg\"]" }
|
||||
```
|
||||
|
||||
### DDAttachment(附件控件)
|
||||
|
||||
> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `DDAttachment` |
|
||||
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
|
||||
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
|
||||
| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
|
||||
|
||||
### StarRatingField(评分控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `StarRatingField` |
|
||||
| value 格式 | 数字字符串 |
|
||||
| 示例 | `"4"` |
|
||||
| 约束 | `limit` 控制最大星数(默认 5) |
|
||||
|
||||
```json
|
||||
{ "name": "满意度评分", "value": "4" }
|
||||
```
|
||||
|
||||
### RelateField(关联审批单)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `RelateField` |
|
||||
| value 格式 | 审批实例 ID 字符串 |
|
||||
| 示例 | `"q-ZZ1sQaTIuYFpKI9aNC1g"` |
|
||||
| 约束 | 须为**当前组织下已存在的审批实例 ID** |
|
||||
|
||||
```json
|
||||
{ "name": "关联审批单", "value": "q-ZZ1sQaTIuYFpKI9aNC1g" }
|
||||
```
|
||||
|
||||
### SignatureField(签名控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `SignatureField` |
|
||||
| value 格式 | 签名图片 mediaId |
|
||||
| 约束 | 需要客户端交互签名,通常不支持 API 直接提交 |
|
||||
|
||||
---
|
||||
|
||||
## 复合控件
|
||||
|
||||
### TableField(明细控件)
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `componentName` | `TableField` |
|
||||
| value 格式 | JSON 数组字符串,每个元素为一行数据的键值对 |
|
||||
| 示例 | `'[{"商品名":"笔记本","数量":"2"},{"商品名":"钢笔","数量":"1"}]'` |
|
||||
| 约束 | **不可嵌套 TableField**;**不可包含 DDMultiSelectField 和 DDPhotoField**;最大 100 行;总长度不超过 65535 字符 |
|
||||
|
||||
模板结构(从 `form-schema` 获取):
|
||||
```json
|
||||
{
|
||||
"componentName": "TableField",
|
||||
"props": { "label": "采购明细" },
|
||||
"children": [
|
||||
{ "componentName": "TextField", "props": { "label": "商品名", "id": "TextField_XXX" } },
|
||||
{ "componentName": "NumberField", "props": { "label": "数量", "id": "NumberField_YYY" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
提交时每行用子控件 label 作 key:
|
||||
```json
|
||||
{
|
||||
"name": "采购明细",
|
||||
"value": "[{\"商品名\":\"笔记本\",\"数量\":\"2\"},{\"商品名\":\"钢笔\",\"数量\":\"1\"}]"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 不支持的控件
|
||||
|
||||
以下控件**不支持**通过创建实例 API 提交,遇到时应告知用户需在钉钉客户端补充:
|
||||
|
||||
| 控件 | componentName | 原因 |
|
||||
|------|---------------|------|
|
||||
| 文字说明 | `TextNote` | 纯展示,不收集数据 |
|
||||
| 计算公式 | `CalculateField` | 由系统自动计算,不可手动填写 |
|
||||
| 流水号 | `SeqNumberField` | 由系统自动生成 |
|
||||
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
|
||||
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
|
||||
| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
|
||||
|
||||
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
|
||||
|
||||
> **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作。
|
||||
|
||||
---
|
||||
|
||||
## 组装优先级
|
||||
|
||||
1. **每次发起前都重新调用 `form-schema`**,不得复用旧结果(模板可能已被修改)
|
||||
2. 先读 `form-schema` 返回的 `content`,识别所有控件的 `label`、`componentName`、`options`、`props.required`
|
||||
3. **检查是否存在不支持控件且为必填项(`props.required: true`)**,若有则直接告知用户该模板不支持通过 CLI 发起,请在钉钉客户端操作
|
||||
4. 按本文档中每种控件的 value 格式组装 `formComponentValues`
|
||||
5. **不要把 `form-schema` 的 `content` 当成可直接提交的模板**
|
||||
6. 遇到 API 不支持的控件(非必填),跳过并告知用户
|
||||
@@ -0,0 +1,374 @@
|
||||
# OA 审批流程节点与审批人规则参考
|
||||
|
||||
本文档描述钉钉 OA 审批的流程节点类型、审批模式、条件分支和审批人选择规则,用于理解审批模板结构和正确填写 `create-instance` 的节点参数。
|
||||
|
||||
---
|
||||
|
||||
## 流程结构概览
|
||||
|
||||
审批流程是一个嵌套树结构:
|
||||
|
||||
- **根节点**:发起人节点(`type: "start"`,`nodeId: "sid-startevent"`),固定不可删除
|
||||
- **后续节点**:通过 `childNode` 链接形成链式结构
|
||||
- **分支节点**:条件分支(`route` + `condition`)或并行分支(`parallel`)
|
||||
- 当没有后续节点时,`childNode` 字段**必须省略**(不可设为 `null`)
|
||||
|
||||
---
|
||||
|
||||
## 7 种节点类型
|
||||
|
||||
### 1. 发起人节点(start)
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `start` |
|
||||
| `nodeId` | `sid-startevent`(固定) |
|
||||
| `properties` | `{}`(空对象) |
|
||||
|
||||
唯一、不可删除。是流程的起点。
|
||||
|
||||
### 2. 审批人节点(approver)
|
||||
|
||||
核心决策节点,有审批/拒绝权限。
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `actionerRules` | Array | 是 | 审批人选择规则,至少一条 |
|
||||
| `activateType` | String | 是 | 多人审批模式(见下方) |
|
||||
| `approvalType` | String | 是 | 固定 `"MANUAL"` |
|
||||
| `agreeAll` | Boolean | 是 | `true` 全部通过 / `false` 任一通过 |
|
||||
| `noneActionerAction` | String | 否 | 如 `"admin"`(找不到审批人时转管理员) |
|
||||
|
||||
支持全部 10 种 actionerRules 类型。
|
||||
|
||||
### 3. 办理人节点(handler)
|
||||
|
||||
执行工作,无审批决策权。
|
||||
|
||||
| 属性 | 类型 | 必填 |
|
||||
|------|------|------|
|
||||
| `actionerRules` | Array | 是 |
|
||||
| `activateType` | String | 是 |
|
||||
|
||||
支持 9 种 actionerRules(不支持 `target_matrix_approval`)。
|
||||
|
||||
### 4. 抄送人节点(notifier)
|
||||
|
||||
仅接收通知,无决策权。
|
||||
|
||||
| 属性 | 类型 | 必填 |
|
||||
|------|------|------|
|
||||
| `actionerRules` | Array | 是 |
|
||||
|
||||
支持多条 actionerRules 组合在一个节点中,实现同时抄送多类人员。
|
||||
|
||||
### 5. 条件分支(route + condition)
|
||||
|
||||
条件路由节点,包含多个条件分支。
|
||||
|
||||
**route 节点:**
|
||||
- `type: "route"`
|
||||
- `conditionNodes[]`:分支数组,按优先级排序,**默认分支必须在最后**
|
||||
- `properties: {}`
|
||||
|
||||
**condition 节点(conditionNodes 的每个元素):**
|
||||
- `type: "condition"`
|
||||
- `isdefault: true`:标记默认分支
|
||||
- `properties.conditions`:二维条件数组
|
||||
- 外层数组:多个条件组,**OR 关系**
|
||||
- 内层数组:多个条件对象,**AND 关系**
|
||||
- 默认分支:`[[]]`(一个空组)
|
||||
|
||||
### 6. 并行分支(parallel)
|
||||
|
||||
多个分支同时执行,全部完成后才继续。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `branches[]` | 分支数组 |
|
||||
| `branches[].name` | 分支名称 |
|
||||
| `branches[].childNode` | 该分支的第一个节点 |
|
||||
|
||||
### 7. 付款人节点(payer)
|
||||
|
||||
财务付款节点。
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `actionerRules` | 审批人规则 |
|
||||
| `paymentConfig.amountField` | 金额控件 ID |
|
||||
| `paymentConfig.accountField` | 收款账户控件 ID |
|
||||
|
||||
---
|
||||
|
||||
## 多人审批模式
|
||||
|
||||
| 模式 | `activateType` | `agreeAll` | 说明 |
|
||||
|------|---------------|-----------|------|
|
||||
| 会签 | `"ALL"` | `true` | 所有审批人都必须审批通过 |
|
||||
| 或签 | `"ALL"` | `false` | 任一审批人审批即可 |
|
||||
| 依次审批 | `"ONE_BY_ONE"` | `true` | 按顺序逐级审批 |
|
||||
|
||||
---
|
||||
|
||||
## 10 种审批人选择规则(actionerRules)
|
||||
|
||||
### 1. 指定成员(target_approval)
|
||||
|
||||
明确指定具体人员。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_approval",
|
||||
"approvals": [
|
||||
{ "userName": "张三", "workNo": "manager123" }
|
||||
],
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `workNo` 必须通过 `dws aisearch person --keyword "<工号>" --dimension jobNumber --format json` 获取,**严禁编造**
|
||||
- 在 `create-instance` 中对应 `directAppointedApprovers` 的 `staffIds`
|
||||
|
||||
### 2. 直属主管(target_formula / reportLineManager)
|
||||
|
||||
按汇报线找到直属主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_formula",
|
||||
"subType": "reportLineManager",
|
||||
"formula": "ReportLineManager(corpId,originator,1)",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `formula` 中最后的数字 N 表示第 N 级主管
|
||||
- **重要区分:** 用户说"直属主管/直属领导/汇报线主管"才用此规则;用户说"主管审批/leader审批"(模糊)时默认用 `target_management`(部门主管)
|
||||
|
||||
### 3. 发起人自己(target_originator)
|
||||
|
||||
发起人自行审批。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_originator",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
最简单的规则,只有 `type` 和 `isEmpty`。
|
||||
|
||||
### 4. 部门主管(target_management)
|
||||
|
||||
从发起人所在部门层级找主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_management",
|
||||
"level": 1,
|
||||
"autoUp": true,
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `level: 1`:直接部门主管
|
||||
- `autoUp: true`:找不到时向上级部门搜索
|
||||
- **这是"主管审批/leader审批"模糊场景的默认选择**
|
||||
|
||||
### 5. 表单部门主管(target_formula / managerOfDept)
|
||||
|
||||
根据表单中部门控件选择的主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_formula",
|
||||
"subType": "managerOfDept",
|
||||
"formula": "ManagerOfDept(corpId,$('DepartmentField_XXX'),1)",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `formula` 中引用表单中的 `DepartmentField` 控件 ID
|
||||
|
||||
### 6. 发起人自选(target_select)
|
||||
|
||||
发起人在提单时自行选择审批人。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_select",
|
||||
"select": ["allStaff"],
|
||||
"range": {},
|
||||
"key": "manual_nodeId_xxxx_yyyy",
|
||||
"multi": 1,
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `select: ["allStaff"]`:可选全组织人员
|
||||
- `multi: 1`:单选
|
||||
- `key`:格式 `manual_{nodeId}_{hex}_{hex}`
|
||||
- 在 `create-instance` 中对应 `targetSelectActioners` 的 `actionerKey`
|
||||
|
||||
### 7. 角色标签主管(target_managers_labels)
|
||||
|
||||
按角色标签找多级主管。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_managers_labels",
|
||||
"labelNames": ["项目经理"],
|
||||
"labels": ["labelId123"],
|
||||
"levels": [1],
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `labels` 中的 ID 必须通过 `dws contact label get --names "<角色名>" --format json` 获取;已知角色名时直接查询,否则先 `dws contact label list --format json` 获取全部角色列表后匹配
|
||||
|
||||
### 8. 表单联系人(target_formcomponent_approval)
|
||||
|
||||
从表单中的联系人控件读取审批人。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_formcomponent_approval",
|
||||
"paramKey": "InnerContactField_XXX",
|
||||
"label": "项目负责人",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `paramKey` 指向表单中的 `InnerContactField` 控件 ID
|
||||
- 该控件中填写的人即为审批人
|
||||
|
||||
### 9. 角色标签(target_label)
|
||||
|
||||
按角色标签找人(如"财务"、"HR")。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_label",
|
||||
"labelNames": "财务",
|
||||
"labels": "459272424",
|
||||
"isEmpty": false
|
||||
}
|
||||
```
|
||||
|
||||
- `labels`:角色标签 ID(字符串),必须通过 `dws contact label get --names "<角色名>" --format json` 获取;未知角色名时先 `dws contact label list --format json`
|
||||
- `labelNames`:角色显示名称
|
||||
- **严禁编造 label ID**
|
||||
|
||||
### 10. 审批矩阵(target_matrix_approval)
|
||||
|
||||
按审批矩阵规则确定审批人。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "target_matrix_approval",
|
||||
"matrixId": "xxx",
|
||||
"roleColumnId": "yyy",
|
||||
"expression": {
|
||||
"subFilters": [...],
|
||||
"operator": "AND"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 仅适用于审批人节点
|
||||
- 目前尚在完善中
|
||||
|
||||
---
|
||||
|
||||
## 条件分支详解
|
||||
|
||||
### 条件类型
|
||||
|
||||
| `type` | 依据 | 关键字段 |
|
||||
|--------|------|---------|
|
||||
| `dingtalk_actioner_dept_condition` | 发起人部门/人员/角色 | `paramKey: "dingtalk_origin_dept"`, `conds[]` |
|
||||
| `dingtalk_actioner_dept_component_condition` | 表单部门控件 | `paramKey: 控件ID`, `conds[]` |
|
||||
| `dingtalk_actioner_range_condition` | 数值/金额/时长范围 | `lowerBound`(>=) / `lowerBoundNotEqual`(>) / `upperBoundEqual`(<=) / `upperBound`(<) / `boundEqual`(=) |
|
||||
| `dingtalk_actioner_value_condition` | 单选匹配 | `paramKey: 控件ID`, `paramValues[]`(选项 key) |
|
||||
| `dingtalk_multi_value_condition` | 多选匹配 | `paramKey: 控件ID`, `paramValues[]`, `matchType`(1=精确/2=全选/3=任一) |
|
||||
| `dingtalk_actioner_cascade_component_condition` | 级联控件 | `paramValues[]`, `displayValues[]` |
|
||||
| `dingtalk_actioner_boolean_condition` | 布尔值 | `boundEqual: true/false` |
|
||||
| `dingtalk_rule_template` | 节假日判断 | `template`, `outVars` |
|
||||
| `dingtalk_formula` | 公式 | `formula`, `formulaDisplay` |
|
||||
| `dingtalk_biz_var_condition` | 业务变量 | `dsKey`, `conds[]` |
|
||||
| `dingtalk_table_condition` | 明细内字段 | `parentFieldId`, `componentName`, `paramValue` |
|
||||
|
||||
### 范围条件操作符
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `lowerBound` | >= (大于等于) |
|
||||
| `lowerBoundNotEqual` | > (大于) |
|
||||
| `upperBoundEqual` | <= (小于等于) |
|
||||
| `upperBound` | < (小于) |
|
||||
| `boundEqual` | = (等于) |
|
||||
|
||||
### 默认分支
|
||||
|
||||
- `isdefault: true`
|
||||
- `conditions: [[]]`(一个空的条件组)
|
||||
- **必须放在 `conditionNodes[]` 的最后**
|
||||
|
||||
---
|
||||
|
||||
## create-instance 中的节点参数映射
|
||||
|
||||
### directAppointedApprovers(指定审批人覆盖模板流程)
|
||||
|
||||
当需要**不使用模板默认流程、直接指定审批人**时使用。
|
||||
|
||||
```json
|
||||
{
|
||||
"directAppointedApprovers": [
|
||||
{
|
||||
"staffIds": ["userId1", "userId2"],
|
||||
"taskActionType": "NONE",
|
||||
"staffId": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `staffIds` | 审批人 userId 列表(通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取;多结果须消歧) |
|
||||
| `taskActionType` | `NONE`(单人)/ `AND`(会签)/ `OR`(或签) |
|
||||
| `staffId` | 留空字符串 |
|
||||
|
||||
### targetSelectActioners(自选审批人)
|
||||
|
||||
当模板流程中存在**自选审批节点**(`target_select` 类型)时必填。
|
||||
|
||||
```json
|
||||
{
|
||||
"targetSelectActioners": [
|
||||
{
|
||||
"actionerKey": "manual_nodeId_xxxx_yyyy",
|
||||
"actionerStaffIds": ["userId1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `actionerKey` | 自选节点的规则 key,从审批流程节点信息接口获取 `actorKey` |
|
||||
| `actionerStaffIds` | 操作人 userId 列表 |
|
||||
|
||||
---
|
||||
|
||||
## 组装优先级
|
||||
|
||||
1. 先用 `forecast-process` 获取模板的流程节点结构(`workflowActivityRuleVOs`)
|
||||
2. 根据节点中的 `activityType` 和 `targetSelect` 判断是否需要传入 `directAppointedApprovers` 或 `targetSelectActioners`
|
||||
3. 如果预测返回 `targetSelect: true` 的自选节点,`targetSelectActioners` 必填
|
||||
4. 如果用户要求覆盖默认流程,使用 `directAppointedApprovers`
|
||||
5. **所有 userId 必须通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取,严禁填姓名;多结果须消歧**
|
||||
|
||||
> **交互优化:** 若用户在 `forecast-process` 前已指定审批人/抄送人姓名,`forecast-process` 返回自选节点后应自动映射,仅对未覆盖的自选节点追问,不要重复询问。详见 [oa.md](../oa.md) 交互优化原则。
|
||||
@@ -547,6 +547,32 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
darwinStart := strings.Index(admission, "\n test-darwin:\n")
|
||||
darwinEnd := strings.Index(admission, "\n test-windows:\n")
|
||||
if darwinStart < 0 || darwinEnd <= darwinStart {
|
||||
t.Fatal("Code Admission workflow missing macOS test job boundaries")
|
||||
}
|
||||
darwinJob := admission[darwinStart:darwinEnd]
|
||||
// The macOS job no longer runs ./internal/app as a whole package, so it does
|
||||
// not need the package-level race budget the Ubuntu shard gets — the Ubuntu
|
||||
// "race: app" shard already covers everything except the natively-gated
|
||||
// tests. Pinning the two focused commands replaces that budget assertion:
|
||||
// it locks the per-step timeouts and blocks a whole-package regression.
|
||||
for _, want := range []string{
|
||||
`go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth`,
|
||||
`go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))'`,
|
||||
} {
|
||||
if !strings.Contains(darwinJob, want) {
|
||||
t.Errorf("macOS native test job missing focused auth contract %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(darwinJob, "./internal/keychain ./internal/auth ./internal/app") {
|
||||
t.Error("macOS native test job must not repeat the complete internal/app race shard")
|
||||
}
|
||||
if count := strings.Count(darwinJob, "./internal/app"); count != 1 {
|
||||
t.Errorf("macOS native test job internal/app invocation count = %d, want 1 focused invocation", count)
|
||||
}
|
||||
|
||||
coverageStart := strings.Index(admission, "\n coverage:\n")
|
||||
coverageEnd := strings.Index(admission, "\n policy:\n")
|
||||
if coverageStart < 0 || coverageEnd <= coverageStart {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMacOSNativeJobKeepsDarwinGatedAppTestsReachable couples the macOS test
|
||||
// job's -run pattern to the set of darwin-gated tests in internal/app.
|
||||
//
|
||||
// The Ubuntu race shard runs the whole package but skips anything gated on
|
||||
// runtime.GOOS != "darwin", and the platform coverage gate only runs
|
||||
// ^(TestAllShortcuts|TestCrossPlatformCoverage). That makes the macOS job the
|
||||
// only place a darwin-gated internal/app test can execute, so narrowing its
|
||||
// -run pattern can silently orphan one — which is exactly what happened in
|
||||
// #857 before review caught it.
|
||||
func TestMacOSNativeJobKeepsDarwinGatedAppTestsReachable(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
|
||||
pattern := macOSAppRunPattern(t, root)
|
||||
matcher, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile(macOS -run pattern %q) error = %v", pattern, err)
|
||||
}
|
||||
|
||||
gated := darwinGatedTestNames(t, filepath.Join(root, "internal", "app"))
|
||||
if len(gated) == 0 {
|
||||
t.Fatal("found no darwin-gated tests in internal/app; the scanner is broken or the gate style changed")
|
||||
}
|
||||
|
||||
for _, name := range gated {
|
||||
if !matcher.MatchString(name) {
|
||||
t.Errorf(
|
||||
"darwin-gated test %s is unreachable in CI: the Ubuntu shard skips it on Linux and the macOS -run pattern %q does not select it",
|
||||
name,
|
||||
pattern,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// macOSAppRunPattern extracts the -run pattern the macOS job applies to
|
||||
// ./internal/app from the CI workflow.
|
||||
func macOSAppRunPattern(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ci.yml) error = %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
start := strings.Index(workflow, "\n test-darwin:\n")
|
||||
end := strings.Index(workflow, "\n test-windows:\n")
|
||||
if start < 0 || end <= start {
|
||||
t.Fatal("ci.yml missing macOS test job boundaries")
|
||||
}
|
||||
|
||||
matches := regexp.MustCompile(`\./internal/app -run '([^']+)'`).FindAllStringSubmatch(workflow[start:end], -1)
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("macOS test job ./internal/app -run invocation count = %d, want exactly 1", len(matches))
|
||||
}
|
||||
return matches[0][1]
|
||||
}
|
||||
|
||||
// darwinGatedTestNames returns every Test function in dir whose body gates on
|
||||
// runtime.GOOS != "darwin".
|
||||
func darwinGatedTestNames(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
|
||||
entries, err := filepath.Glob(filepath.Join(dir, "*_test.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Glob(%s) error = %v", dir, err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
fset := token.NewFileSet()
|
||||
for _, entry := range entries {
|
||||
file, parseErr := parser.ParseFile(fset, entry, nil, parser.SkipObjectResolution)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("ParseFile(%s) error = %v", entry, parseErr)
|
||||
}
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Body == nil || !strings.HasPrefix(fn.Name.Name, "Test") {
|
||||
continue
|
||||
}
|
||||
if gatesOnNonDarwin(fn.Body) {
|
||||
names = append(names, fn.Name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// gatesOnNonDarwin reports whether body contains a `runtime.GOOS != "darwin"`
|
||||
// comparison, the idiom this repo uses to skip a test off macOS.
|
||||
func gatesOnNonDarwin(body *ast.BlockStmt) bool {
|
||||
found := false
|
||||
ast.Inspect(body, func(node ast.Node) bool {
|
||||
if found {
|
||||
return false
|
||||
}
|
||||
binary, ok := node.(*ast.BinaryExpr)
|
||||
if !ok || binary.Op != token.NEQ {
|
||||
return true
|
||||
}
|
||||
if !isRuntimeGOOS(binary.X) {
|
||||
return true
|
||||
}
|
||||
literal, ok := binary.Y.(*ast.BasicLit)
|
||||
if !ok || literal.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
if literal.Value == `"darwin"` {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// isRuntimeGOOS reports whether expr is the selector runtime.GOOS.
|
||||
func isRuntimeGOOS(expr ast.Expr) bool {
|
||||
selector, ok := expr.(*ast.SelectorExpr)
|
||||
if !ok || selector.Sel.Name != "GOOS" {
|
||||
return false
|
||||
}
|
||||
ident, ok := selector.X.(*ast.Ident)
|
||||
return ok && ident.Name == "runtime"
|
||||
}
|
||||
Reference in New Issue
Block a user