@xinhe-oa/cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -2
- package/bin/xinhe-oa-cli.js +13 -0
- package/package.json +40 -4
- package/src/commands/application.js +244 -0
- package/src/commands/approval.js +275 -0
- package/src/commands/attachment.js +202 -0
- package/src/commands/bitable.js +3040 -0
- package/src/commands/config.js +186 -0
- package/src/commands/dashboard.js +132 -0
- package/src/commands/doctor.js +975 -0
- package/src/commands/event.js +151 -0
- package/src/commands/file.js +1474 -0
- package/src/commands/index.js +78 -0
- package/src/commands/investor.js +179 -0
- package/src/commands/member.js +1341 -0
- package/src/commands/org.js +399 -0
- package/src/commands/report.js +908 -0
- package/src/commands/schema.js +75 -0
- package/src/commands/session.js +516 -0
- package/src/commands/skills.js +1117 -0
- package/src/commands/tenant.js +202 -0
- package/src/commands/workflow.js +176 -0
- package/src/core/args.js +901 -0
- package/src/core/auth.js +349 -0
- package/src/core/checkpoint.js +226 -0
- package/src/core/config.js +407 -0
- package/src/core/error.js +217 -0
- package/src/core/http.js +903 -0
- package/src/core/import.js +753 -0
- package/src/core/output.js +300 -0
- package/src/core/runtime.js +302 -0
- package/src/core/util.js +195 -0
- package/src/core/values.js +278 -0
- package/src/core/xlsx.js +321 -0
- package/src/main.js +205 -0
- package/src/spec/agent_guide.md +55 -0
- package/src/spec/command_surface.json +27073 -0
- package/src/spec/embedded_skills.json +19 -0
- package/src/version.js +16 -0
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
// 成员域:`member --list|--search|--create|--role|--revoke-role|--remove`(契约 §2、§6.2),
|
|
2
|
+
// 含 ORG-001 的部门归属三件套(`--set-dept` / `--unset-dept` / `--set-manager|--clear-manager`)、
|
|
3
|
+
// ORG-002 的授权三动作(`--grant|--revoke-grant|--grants`,issue #82)、
|
|
4
|
+
// ORG-004 的离职 / 移交四动作(issue #81)与 MEMBER-001 的 `--search`(issue #99)。
|
|
5
|
+
//
|
|
6
|
+
// ## 为什么这一域的动作判定要**自己做**(而不是看 `command.name`)
|
|
7
|
+
//
|
|
8
|
+
// Rust 版把 16 个动作塞进**同一个** `MemberArgs` 结构体(clap 的互斥组装不下
|
|
9
|
+
// “`--role` 既是动词又是 `--create` 的参数”),动作由 `MemberArgs::action()` 在本地判;
|
|
10
|
+
// `--search` / `--offboard` 这些动词的取值(`--offboard usr_x` 的 `usr_x`)也是
|
|
11
|
+
// **动作名后面的位置词**,clap 把它们当成旗标取值。
|
|
12
|
+
//
|
|
13
|
+
// Node 侧的命令规格是“散文”(`schema --json` 的 `params`),`member --offboard usr_x`
|
|
14
|
+
// 在规格里最长的前缀命中可能是别的兄弟命令(实测会落到 `member --transfer-list`)。
|
|
15
|
+
// 所以这一域**不看 `command.name`**:`memberAction()` 是 `MemberArgs::action()` 的逐行移植,
|
|
16
|
+
// 动作、必填、互斥、逐动词的参数面全在这一处判死 —— 这样"哪条候选赢"就与人无关了。
|
|
17
|
+
//
|
|
18
|
+
// ## 三条本地纪律(一个请求都不发,退出码 2)
|
|
19
|
+
//
|
|
20
|
+
// 1. **`--list` 只列举、不带条件**:`--name` / `--phone` / `--dept` 出现在它上面直接报错
|
|
21
|
+
// 并指路 `--search`(MEMBER-001 的行为变更:过去前两个被静默忽略、`--dept` 只把过滤
|
|
22
|
+
// 下推给部门归属那一半,人仍来自全量 `GET /admin/users` = 静默返回全公司的人)。
|
|
23
|
+
// 2. **`--grant` 主体多态**:`--user` 与 `--dept` 互斥,都不给也是错;
|
|
24
|
+
// `--dept-scope CUSTOM` 没有 `--dept-id` 是错(服务端对“声明 CUSTOM_DEPTS 却没给 ID”
|
|
25
|
+
// 回 400,契约要求在这里本地判死);`--dept-scope` 用在非数据动作(grant / ddl / admin:*)
|
|
26
|
+
// 上同样是错 —— 它是**行范围**,不是能力面。
|
|
27
|
+
// 3. **ORG-004 的高风险写**:`--offboard` / `--transfer` / `--transfer-revert` 没有 `--yes`
|
|
28
|
+
// 一律退出码 2;**`--offboard --dry-run` 是例外**(见 `offboard()` 的注释)。
|
|
29
|
+
//
|
|
30
|
+
// 退出码与错误分类由 `main.js` 统一收口;服务端的 `request.invalid /
|
|
31
|
+
// transfer_target_required` 由 `core/http.js` 归到 2,这里**不覆盖**。
|
|
32
|
+
|
|
33
|
+
import { CliError, Kind } from '../core/error.js';
|
|
34
|
+
import { flagList, flagOn, flagValue, intFlag } from '../core/args.js';
|
|
35
|
+
import { dataOf, idempotencyKey, requestSpec } from '../core/runtime.js';
|
|
36
|
+
import { warn } from '../core/output.js';
|
|
37
|
+
import { queryPairs } from '../core/values.js';
|
|
38
|
+
|
|
39
|
+
/** 合法角色(与服务端 `ROLE_TEMPLATES` 对齐;`tenant_member` 是契约 §6.2 的业务员角色)。 */
|
|
40
|
+
const ROLES = ['super_admin', 'tenant_owner', 'tenant_member', 'tenant_operator', 'investor'];
|
|
41
|
+
|
|
42
|
+
/** `--dept-scope` 只对**数据动作**有意义(行范围);能力面动作没有部门档位。 */
|
|
43
|
+
const DATA_ACTIONS = ['read', 'write', 'export', 'delete'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 服务端 `GET /admin/users` 的硬上限(既有限举路径,客户端无法翻页)。
|
|
47
|
+
*
|
|
48
|
+
* 命中整整这么多条时 `--list` 会带 `truncated: true` —— 这是**下界**判断:
|
|
49
|
+
* 企业恰好有 500 人时也会报 `true`(宁可提示“可能被截断”,也不要静默漏人)。
|
|
50
|
+
*/
|
|
51
|
+
const LIST_PAGE_LIMIT = 500;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `u32` 的上界(`--limit` / `--offset` 在 Rust 里就是 `Option<u32>`)。
|
|
55
|
+
*
|
|
56
|
+
* 超过它 clap 会在解析层拒掉(退出码 2);这里补同一道上界,免得把一个
|
|
57
|
+
* 服务端必然 422 的值发出去。
|
|
58
|
+
*/
|
|
59
|
+
const U32_MAX = 4294967295;
|
|
60
|
+
|
|
61
|
+
const OFFBOARD_EXAMPLE = 'xinhe-oa-cli member --offboard usr_tang --reason 离职交接 --yes';
|
|
62
|
+
const TRANSFER_EXAMPLE =
|
|
63
|
+
'xinhe-oa-cli member --transfer usr_tang --to usr_owner --reason 岗位调整 --yes';
|
|
64
|
+
const ACTION_HINT =
|
|
65
|
+
'例:xinhe-oa-cli member --create --phone 13800000001 --name 小唐 --role tenant_member --tenant kashu';
|
|
66
|
+
|
|
67
|
+
/** 分发入口:先本地判动作(不发请求),再按动作走各自的端点。 */
|
|
68
|
+
export async function handleMember({ args, runtime }) {
|
|
69
|
+
const action = memberAction(args);
|
|
70
|
+
switch (action.kind) {
|
|
71
|
+
case 'list':
|
|
72
|
+
// `--list --user <id>` = 看某人的角色与展开后的授权(另一条只读路径)。
|
|
73
|
+
return action.user === null ? list(runtime, args) : rolesOf(runtime, action.user);
|
|
74
|
+
case 'search':
|
|
75
|
+
return search(runtime, args, action);
|
|
76
|
+
case 'create':
|
|
77
|
+
return create(runtime, args);
|
|
78
|
+
case 'assign':
|
|
79
|
+
return assign(runtime, args, action.role, action.user);
|
|
80
|
+
case 'revoke-role':
|
|
81
|
+
return revokeRole(runtime, args, action.role, action.user);
|
|
82
|
+
case 'remove':
|
|
83
|
+
return removeMember(runtime, action.user, args);
|
|
84
|
+
case 'set-dept':
|
|
85
|
+
return setDept(runtime, args, action.user, action.deptId, action.primary);
|
|
86
|
+
case 'unset-dept':
|
|
87
|
+
return unsetDept(runtime, args, action.user, action.deptId);
|
|
88
|
+
case 'set-manager':
|
|
89
|
+
return setManager(runtime, args, action.user, action.manager);
|
|
90
|
+
case 'grant':
|
|
91
|
+
return grant(runtime, args, action);
|
|
92
|
+
case 'revoke-grant':
|
|
93
|
+
return revokeGrant(runtime, args, action.grantId);
|
|
94
|
+
case 'grants':
|
|
95
|
+
return listGrants(runtime, args, action.user, action.dept);
|
|
96
|
+
case 'offboard':
|
|
97
|
+
return offboard(runtime, args, action);
|
|
98
|
+
case 'transfer':
|
|
99
|
+
return transfer(runtime, args, action);
|
|
100
|
+
case 'transfer-list':
|
|
101
|
+
return transferList(runtime, args, action);
|
|
102
|
+
case 'transfer-revert':
|
|
103
|
+
return transferRevert(runtime, args, action.transferId, action.note);
|
|
104
|
+
default:
|
|
105
|
+
throw CliError.usage('`member` 必须给一个动作').withHint(ACTION_HINT);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// 动作判定:`MemberArgs::action()` 的逐行移植(本地判定,退出码 2、不发请求)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 把一串旗标收成一个动作,并按 Rust 的顺序做完全部本地校验。
|
|
115
|
+
*
|
|
116
|
+
* 顺序是**行为的一部分**:同一个错误参数组合,Rust 先报哪一条,这里就得先报哪一条
|
|
117
|
+
* (否则错误文案与退出码相同、但 `error.message` 漂移,判卷与用户都会困惑)。
|
|
118
|
+
*/
|
|
119
|
+
function memberAction(args) {
|
|
120
|
+
const on = (flag) => flagOn(args, flag);
|
|
121
|
+
const val = (flag) => flagValue(args, flag);
|
|
122
|
+
const given = (flag) => flagValue(args, flag) !== null;
|
|
123
|
+
|
|
124
|
+
const picked = exactlyOne(
|
|
125
|
+
[
|
|
126
|
+
['--list', on('--list')],
|
|
127
|
+
['--create', on('--create')],
|
|
128
|
+
// `--role` 跟着 `--create` 时是参数,不是动词。
|
|
129
|
+
['--role', given('--role') && !on('--create')],
|
|
130
|
+
['--revoke-role', given('--revoke-role')],
|
|
131
|
+
['--remove', given('--remove')],
|
|
132
|
+
['--set-dept', given('--set-dept')],
|
|
133
|
+
['--unset-dept', given('--unset-dept')],
|
|
134
|
+
['--set-manager', given('--set-manager')],
|
|
135
|
+
['--clear-manager', on('--clear-manager')],
|
|
136
|
+
['--grant', on('--grant')],
|
|
137
|
+
['--revoke-grant', given('--revoke-grant')],
|
|
138
|
+
['--grants', on('--grants')],
|
|
139
|
+
['--offboard', given('--offboard')],
|
|
140
|
+
['--transfer', given('--transfer')],
|
|
141
|
+
['--transfer-list', on('--transfer-list')],
|
|
142
|
+
['--transfer-revert', given('--transfer-revert')],
|
|
143
|
+
['--search', on('--search')],
|
|
144
|
+
],
|
|
145
|
+
'member',
|
|
146
|
+
ACTION_HINT,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// MEMBER-001:`--list` **只列举、不带条件**。这一条是刻意的行为变更:
|
|
150
|
+
// 静默忽略一个“看起来生效了”的过滤条件,比报错危险得多(调用方会以为筛过了)。
|
|
151
|
+
if (picked === '--list') {
|
|
152
|
+
const wrong = [];
|
|
153
|
+
if (on('--name')) wrong.push('--name');
|
|
154
|
+
if (on('--phone')) wrong.push('--phone');
|
|
155
|
+
if (on('--dept')) wrong.push('--dept');
|
|
156
|
+
if (wrong.length > 0) {
|
|
157
|
+
throw CliError.usage(
|
|
158
|
+
`\`member --list\` 不接受 ${wrong.join(' / ')}:列举不带条件(过去 --name/--phone 被静默忽略、--dept 只标注不过滤)`,
|
|
159
|
+
).withHint(
|
|
160
|
+
'按姓名 / 精确手机号 / 部门查人请用 `member --search`(例:member --search --name 小唐 --json)',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 检索条件是 --name / --phone / --dept,`--user` 是“看某个人”,两回事。
|
|
166
|
+
if (picked === '--search' && on('--user')) {
|
|
167
|
+
throw CliError.usage(
|
|
168
|
+
'`member --search` 不接受 --user:检索条件用 --name / --phone / --dept',
|
|
169
|
+
).withHint('要看某个人的角色与授权:member --list --user <USER_ID>');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ORG-003:部门档位只属于 `--grant` 的数据动作;别的动词上出现 = 参数拼错。
|
|
173
|
+
if (
|
|
174
|
+
picked !== '--grant' &&
|
|
175
|
+
(on('--dept-scope') || flagList(args, '--dept-id').length > 0 || on('--include-sub') || on('--include-unassigned'))
|
|
176
|
+
) {
|
|
177
|
+
throw CliError.usage(
|
|
178
|
+
`\`member ${picked}\` 不接受部门档位参数(--dept-scope / --dept-id / --include-sub / --include-unassigned)`,
|
|
179
|
+
).withHint('部门档位只属于 `member --grant` 的数据动作(read / write / export / delete)');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ORG-004:每个动词只接受自己的参数(`--dry-run` 是全局旗标,不在这里管)。
|
|
183
|
+
// 出现在别的动词上 = 参数拼错 —— 拼错的参数被忽略,等于让人以为它生效了。
|
|
184
|
+
const bad = [];
|
|
185
|
+
const check = (present, flag, allowed) => {
|
|
186
|
+
if (present && !allowed.includes(picked)) bad.push(flag);
|
|
187
|
+
};
|
|
188
|
+
check(given('--scope'), '--scope', ['--offboard', '--transfer']);
|
|
189
|
+
check(given('--personal'), '--personal', ['--offboard']);
|
|
190
|
+
check(given('--transfer-to'), '--transfer-to', ['--offboard']);
|
|
191
|
+
check(given('--to'), '--to', ['--transfer', '--transfer-list']);
|
|
192
|
+
check(given('--from'), '--from', ['--transfer-list']);
|
|
193
|
+
check(given('--status'), '--status', ['--transfer-list']);
|
|
194
|
+
check(given('--note'), '--note', ['--grant', '--transfer-revert']);
|
|
195
|
+
check(given('--name'), '--name', ['--create', '--search']);
|
|
196
|
+
check(given('--phone'), '--phone', ['--create', '--search']);
|
|
197
|
+
check(given('--dept'), '--dept', ['--grant', '--grants', '--search']);
|
|
198
|
+
check(given('--limit'), '--limit', ['--search']);
|
|
199
|
+
check(given('--offset'), '--offset', ['--search']);
|
|
200
|
+
check(on('--include-departed'), '--include-departed', ['--search']);
|
|
201
|
+
if (bad.length > 0) {
|
|
202
|
+
throw CliError.usage(`\`member ${picked}\` 不接受 ${bad.join(' / ')}`).withHint(
|
|
203
|
+
'跑 `xinhe-oa-cli schema member` 看每个动作的参数面',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const needUser = (verb) => {
|
|
208
|
+
const raw = val('--user');
|
|
209
|
+
const value = raw === null ? '' : String(raw);
|
|
210
|
+
if (value.trim().length === 0) {
|
|
211
|
+
throw CliError.usage(`\`member ${verb}\` 需要 --user <USER_ID>`).withHint(
|
|
212
|
+
'先看 user_id:xinhe-oa-cli member --list --json',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
};
|
|
217
|
+
const needDept = (raw, verb) => {
|
|
218
|
+
const value = normalizeId(raw);
|
|
219
|
+
if (value === null) {
|
|
220
|
+
throw CliError.usage(`\`member ${verb}\` 需要 ${verb} <DEPT_ID>`).withHint(
|
|
221
|
+
'先看部门:xinhe-oa-cli org --tree --json',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
};
|
|
226
|
+
const requiredId = (raw, what, example) => {
|
|
227
|
+
const value = normalizeId(raw);
|
|
228
|
+
if (value === null) {
|
|
229
|
+
throw CliError.usage(`\`member ${what}\` 需要 ${what} <ID>`).withHint(example);
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// MEMBER-001:检索条件在这里**本地判一遍**(退出码 2、不发请求)。
|
|
235
|
+
// 服务端也判(它才是权威),本地判只是为了不让“拼错的检索”变成一次往返,
|
|
236
|
+
// 也让 `--dry-run` 不产生“看起来能跑”的假象。
|
|
237
|
+
const name = normalizeId(val('--name'));
|
|
238
|
+
const phone = normalizeId(val('--phone'));
|
|
239
|
+
const dept = normalizeId(val('--dept'));
|
|
240
|
+
if (picked === '--search') {
|
|
241
|
+
if (name === null && phone === null && dept === null) {
|
|
242
|
+
throw CliError.usage(
|
|
243
|
+
'`member --search` 至少要给一个条件:--name / --phone / --dept',
|
|
244
|
+
).withHint('不带条件的列举用 `member --list`;只记得号码就用 `--phone <11 位号码>`');
|
|
245
|
+
}
|
|
246
|
+
if (name !== null) {
|
|
247
|
+
const size = charCount(name);
|
|
248
|
+
if (size < 2 || size > 32) {
|
|
249
|
+
throw CliError.usage(`\`--name\` 需要 2..=32 个字(当前 ${size} 个)`).withHint(
|
|
250
|
+
'单字会命中太多人;知道号码就用 `--phone` 精确查',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (phone !== null && !isExactPhone(phone)) {
|
|
255
|
+
throw CliError.usage(`\`--phone\` 只支持精确号码(11 位):${JSON.stringify(phone)}`).withHint(
|
|
256
|
+
'检索不做前缀 / 号段 / 模糊匹配;不确定号码就用 `--name` 或 `--dept`',
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
switch (picked) {
|
|
262
|
+
case '--list': {
|
|
263
|
+
// 刻意用**未去空白**的原值:Rust 这里就是 `self.user.clone()`,
|
|
264
|
+
// `--user ""` 会拼出 `/admin/users//roles`(保持逐字一致,不做“善意修正”)。
|
|
265
|
+
const raw = val('--user');
|
|
266
|
+
return { kind: 'list', user: raw === null ? null : String(raw) };
|
|
267
|
+
}
|
|
268
|
+
case '--search': {
|
|
269
|
+
// 分页在本地判范围:服务端 `Query(le=200)` 的 422 不该丢给调用方。
|
|
270
|
+
// 先按“无符号 32 位整数”解析(负数、小数、溢出都是 clap 的 `u32` 层面就拒的写法),
|
|
271
|
+
// 再判 1..=200 —— 这样 `--limit 0` 拿到的是 Rust 那句“必须在 1..=200 之间”。
|
|
272
|
+
const limit = intFlag(args, '--limit', { min: 0, max: U32_MAX });
|
|
273
|
+
if (limit !== null && (limit < 1 || limit > 200)) {
|
|
274
|
+
throw CliError.usage('`--limit` 必须在 1..=200 之间').withHint(
|
|
275
|
+
'缺省 20;翻页把响应里的 `next_offset` 喂给 `--offset`',
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const offset = intFlag(args, '--offset', { min: 0, max: U32_MAX });
|
|
279
|
+
return { kind: 'search', name, phone, dept, limit, offset };
|
|
280
|
+
}
|
|
281
|
+
case '--create':
|
|
282
|
+
return { kind: 'create' };
|
|
283
|
+
case '--role':
|
|
284
|
+
return { kind: 'assign', role: String(val('--role')), user: needUser('--role') };
|
|
285
|
+
case '--revoke-role':
|
|
286
|
+
return {
|
|
287
|
+
kind: 'revoke-role',
|
|
288
|
+
role: String(val('--revoke-role')),
|
|
289
|
+
user: needUser('--revoke-role'),
|
|
290
|
+
};
|
|
291
|
+
case '--set-dept':
|
|
292
|
+
return {
|
|
293
|
+
kind: 'set-dept',
|
|
294
|
+
user: needUser('--set-dept'),
|
|
295
|
+
deptId: needDept(val('--set-dept'), '--set-dept'),
|
|
296
|
+
primary: on('--primary'),
|
|
297
|
+
};
|
|
298
|
+
case '--unset-dept':
|
|
299
|
+
return {
|
|
300
|
+
kind: 'unset-dept',
|
|
301
|
+
user: needUser('--unset-dept'),
|
|
302
|
+
deptId: needDept(val('--unset-dept'), '--unset-dept'),
|
|
303
|
+
};
|
|
304
|
+
case '--set-manager': {
|
|
305
|
+
const manager = normalizeId(val('--set-manager'));
|
|
306
|
+
if (manager === null) {
|
|
307
|
+
throw CliError.usage('`member --set-manager` 需要 --set-manager <USER_ID>').withHint(
|
|
308
|
+
'清空用:xinhe-oa-cli member --clear-manager --user <USER_ID>',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return { kind: 'set-manager', user: needUser('--set-manager'), manager };
|
|
312
|
+
}
|
|
313
|
+
case '--clear-manager':
|
|
314
|
+
return { kind: 'set-manager', user: needUser('--clear-manager'), manager: null };
|
|
315
|
+
case '--grant': {
|
|
316
|
+
// 主体二选一:人的授权带 user_id,部门的授权带 subject_type='dept' + subject_id。
|
|
317
|
+
// 互斥在这里判死 —— `--user` 与 `--dept` 同时给,服务端只会取一个,
|
|
318
|
+
// 而调用方以为自己两个都授了。
|
|
319
|
+
const subjectUser = normalizeId(val('--user'));
|
|
320
|
+
const subjectDept = normalizeId(val('--dept'));
|
|
321
|
+
if (subjectUser !== null && subjectDept !== null) {
|
|
322
|
+
throw CliError.usage('`member --grant` 的 --user 与 --dept 互斥(主体只能有一个)').withHint(
|
|
323
|
+
'给人的授权用 --user <USER_ID>;给部门的授权用 --dept <DEPT_ID>',
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (subjectUser === null && subjectDept === null) {
|
|
327
|
+
throw CliError.usage('`member --grant` 需要 --user 或 --dept 之一').withHint(
|
|
328
|
+
'给人的授权用 --user <USER_ID>;给部门的授权用 --dept <DEPT_ID>',
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
let deptScope = null;
|
|
332
|
+
const rawScope = normalizeId(val('--dept-scope'));
|
|
333
|
+
if (rawScope !== null) {
|
|
334
|
+
const action = normalizeId(val('--action'));
|
|
335
|
+
if (action === null) {
|
|
336
|
+
throw CliError.usage('`member --grant` 需要 --action <ACTION>').withHint(
|
|
337
|
+
'动作集与服务端一致:read / write / export / delete / ddl / grant …',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (!DATA_ACTIONS.includes(action)) {
|
|
341
|
+
throw CliError.usage(
|
|
342
|
+
`--dept-scope 只对数据动作有意义(read / write / export / delete),${JSON.stringify(action)} 不是数据动作`,
|
|
343
|
+
).withHint('部门档位是行范围,不是能力面:grant / ddl / admin:* 这类授权没有部门档位');
|
|
344
|
+
}
|
|
345
|
+
deptScope = parseDeptScope(
|
|
346
|
+
rawScope,
|
|
347
|
+
flagList(args, '--dept-id'),
|
|
348
|
+
on('--include-sub'),
|
|
349
|
+
on('--include-unassigned'),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return { kind: 'grant', subjectUser, subjectDept, deptScope };
|
|
353
|
+
}
|
|
354
|
+
case '--revoke-grant':
|
|
355
|
+
return {
|
|
356
|
+
kind: 'revoke-grant',
|
|
357
|
+
grantId: String(val('--revoke-grant') ?? ''),
|
|
358
|
+
};
|
|
359
|
+
case '--grants':
|
|
360
|
+
return { kind: 'grants', user: normalizeId(val('--user')), dept: normalizeId(val('--dept')) };
|
|
361
|
+
case '--offboard':
|
|
362
|
+
return {
|
|
363
|
+
kind: 'offboard',
|
|
364
|
+
user: requiredId(val('--offboard'), '--offboard', OFFBOARD_EXAMPLE),
|
|
365
|
+
transferTo: normalizeId(val('--transfer-to')),
|
|
366
|
+
scope: parseTransferScope(val('--scope')),
|
|
367
|
+
personal: parsePersonal(val('--personal')),
|
|
368
|
+
reason: normalizeId(val('--reason')),
|
|
369
|
+
};
|
|
370
|
+
case '--transfer':
|
|
371
|
+
return {
|
|
372
|
+
kind: 'transfer',
|
|
373
|
+
from: requiredId(val('--transfer'), '--transfer', TRANSFER_EXAMPLE),
|
|
374
|
+
to: requiredId(val('--to'), '--transfer --to', TRANSFER_EXAMPLE),
|
|
375
|
+
scope: parseTransferScope(val('--scope')),
|
|
376
|
+
reason: normalizeId(val('--reason')),
|
|
377
|
+
};
|
|
378
|
+
case '--transfer-list':
|
|
379
|
+
return {
|
|
380
|
+
kind: 'transfer-list',
|
|
381
|
+
from: normalizeId(val('--from')),
|
|
382
|
+
to: normalizeId(val('--to')),
|
|
383
|
+
status: parseTransferStatus(val('--status')),
|
|
384
|
+
};
|
|
385
|
+
case '--transfer-revert': {
|
|
386
|
+
const transferId = requiredId(
|
|
387
|
+
val('--transfer-revert'),
|
|
388
|
+
'--transfer-revert',
|
|
389
|
+
'例:xinhe-oa-cli member --transfer-revert xfr_01H --note 应移交给财务 --yes',
|
|
390
|
+
);
|
|
391
|
+
const note = val('--note') ?? '';
|
|
392
|
+
if (charCount(String(note).trim()) < 2) {
|
|
393
|
+
throw CliError.usage('`member --transfer-revert` 需要 --note(写进审计)').withHint(
|
|
394
|
+
`例:xinhe-oa-cli member --transfer-revert ${transferId} --note 应移交给财务 --yes`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return { kind: 'transfer-revert', transferId, note: String(note) };
|
|
398
|
+
}
|
|
399
|
+
default:
|
|
400
|
+
return { kind: 'remove', user: String(val('--remove') ?? '') };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 0 个或多于 1 个动作都是用法错误(与 Rust `exactly_one` 同文案)。 */
|
|
405
|
+
function exactlyOne(selected, what, hint) {
|
|
406
|
+
const picked = selected.filter(([, active]) => active).map(([name]) => name);
|
|
407
|
+
if (picked.length === 1) return picked[0];
|
|
408
|
+
if (picked.length === 0) {
|
|
409
|
+
throw CliError.usage(`\`${what}\` 必须给一个动作`).withHint(hint);
|
|
410
|
+
}
|
|
411
|
+
throw CliError.usage(
|
|
412
|
+
`\`${what}\` 一次只能给一个动作,收到 ${picked.length} 个:${picked.join(' / ')}`,
|
|
413
|
+
).withHint(hint);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// `member --list` / `--list --user <id>`
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* `member --list [--dept <DEPT_ID>]`:成员清单,并进部门归属 `departments`。
|
|
422
|
+
*
|
|
423
|
+
* 两个来源:`GET /admin/users`(人)+ `GET /org/members`(归属,login 能力)。
|
|
424
|
+
* `/org/members` 拿不到(比如后端还没升到 ORG-001)时**清单照出**,
|
|
425
|
+
* `departments` 落成空数组 —— 读清单不能被一张新表绑架。
|
|
426
|
+
*/
|
|
427
|
+
async function list(runtime, args) {
|
|
428
|
+
const orgQuery = listOrgQuery(args);
|
|
429
|
+
if (runtime.dryRun) {
|
|
430
|
+
// Rust 在这一处给的是**多请求清单**(`requests`),不是通用的单请求报告:
|
|
431
|
+
// 只打印第一个 GET 会让人以为“列举”只读一张表。
|
|
432
|
+
const requests = [
|
|
433
|
+
dryRunRequestOf(runtime, 'GET', '/admin/users', requestSpec()),
|
|
434
|
+
dryRunRequestOf(runtime, 'GET', '/org/members', requestSpec({ query: orgQuery })),
|
|
435
|
+
];
|
|
436
|
+
runtime.finish({
|
|
437
|
+
dry_run: true,
|
|
438
|
+
requests,
|
|
439
|
+
note: '--dry-run:没有发出任何请求。真跑会读成员清单 + 部门归属两个 GET',
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const response = await runtime.sendAuthed('GET', '/admin/users', requestSpec());
|
|
444
|
+
const data = dataOf(response);
|
|
445
|
+
|
|
446
|
+
// 部门归属:按 user_id 索引;这一段的任何失败都不该让清单读不出来。
|
|
447
|
+
const departmentsOf = new Map();
|
|
448
|
+
try {
|
|
449
|
+
const orgResponse = await runtime.sendAuthed(
|
|
450
|
+
'GET',
|
|
451
|
+
'/org/members',
|
|
452
|
+
requestSpec({ query: orgQuery }),
|
|
453
|
+
);
|
|
454
|
+
for (const item of asArray(pick(dataOf(orgResponse), 'items'))) {
|
|
455
|
+
const userId = pick(item, 'user_id');
|
|
456
|
+
if (typeof userId !== 'string') continue;
|
|
457
|
+
// 键在就是值(可能是 null —— 服务端说了“没有部门”与“字段缺失”不是一回事)。
|
|
458
|
+
departmentsOf.set(
|
|
459
|
+
userId,
|
|
460
|
+
isObject(item) && 'departments' in item ? item.departments : [],
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// 读清单不能被一张新表绑架(旧后端没有 /org/members)。
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const members = asArray(pick(data, 'items')).map((item) => ({
|
|
468
|
+
user_id: pick(item, 'user_id'),
|
|
469
|
+
name: pick(item, 'name'),
|
|
470
|
+
status: pick(item, 'status'),
|
|
471
|
+
is_bootstrap: pick(item, 'is_bootstrap'),
|
|
472
|
+
identities: asArray(pick(item, 'identities')).map((row) => ({
|
|
473
|
+
identity_id: pick(row, 'id'),
|
|
474
|
+
phone_masked: pick(row, 'value_masked'),
|
|
475
|
+
tenant_id: pick(row, 'tenant_id'),
|
|
476
|
+
revoked_at: pick(row, 'revoked_at'),
|
|
477
|
+
})),
|
|
478
|
+
// ORG-001:[{dept_id, is_primary, title}];“无部门”是合法状态(空数组)。
|
|
479
|
+
departments: departmentsOf.has(pick(item, 'user_id'))
|
|
480
|
+
? departmentsOf.get(pick(item, 'user_id'))
|
|
481
|
+
: [],
|
|
482
|
+
grant_count: pick(item, 'grant_count'),
|
|
483
|
+
last_login_at: pick(item, 'last_login_at'),
|
|
484
|
+
}));
|
|
485
|
+
runtime.finishFrom(response, {
|
|
486
|
+
members,
|
|
487
|
+
// **不是**服务端的 total:服务端根本不给 total,这里的口径是“这一次拿到几条”。
|
|
488
|
+
total: members.length,
|
|
489
|
+
truncated: members.length >= LIST_PAGE_LIMIT,
|
|
490
|
+
note: pick(data, 'note'),
|
|
491
|
+
next_steps: [
|
|
492
|
+
'member --create --phone <号码> --name <姓名> --role tenant_member --tenant <企业> 建业务员',
|
|
493
|
+
'member --list --user <user_id> 看某人的角色',
|
|
494
|
+
'member --search --name <姓名> 按姓名/号码/部门检索(不受 500 条限制)',
|
|
495
|
+
'member --set-dept <DEPT_ID> --user <user_id> 把人挂进部门',
|
|
496
|
+
],
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* `--list` 的部门过滤(`/org/members?dept_id=`)。
|
|
502
|
+
*
|
|
503
|
+
* MEMBER-001 起 `--list` 不再接受 `--dept`(上面已判死),所以这一段**恒为空** ——
|
|
504
|
+
* 保留它是为了与 Rust 的形状逐字一致(历史分支、以及将来若恢复过滤不必再改口径)。
|
|
505
|
+
*/
|
|
506
|
+
function listOrgQuery(args) {
|
|
507
|
+
const dept = normalizeId(flagValue(args, '--dept'));
|
|
508
|
+
return dept === null ? [] : [['dept_id', dept]];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** `member --list --user <id>`:看某人的角色与展开后的授权(原样透传服务端数据)。 */
|
|
512
|
+
async function rolesOf(runtime, userId) {
|
|
513
|
+
const response = await runtime.sendAuthed(
|
|
514
|
+
'GET',
|
|
515
|
+
`/admin/users/${userId}/roles`,
|
|
516
|
+
requestSpec(),
|
|
517
|
+
);
|
|
518
|
+
runtime.finishFrom(response, dataOf(response));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// `member --search`(MEMBER-001 / issue #99)
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 按姓名 / 精确手机号 / 部门检索成员。
|
|
527
|
+
*
|
|
528
|
+
* 服务端已经把每行装配成与 `--list` 同形的成员对象(含脱敏号与部门),**原样透传** ——
|
|
529
|
+
* 在客户端再拼一次只会多一处漂移点。条件的合法性在 `memberAction()` 里已判。
|
|
530
|
+
*/
|
|
531
|
+
async function search(runtime, args, action) {
|
|
532
|
+
const spec = requestSpec({
|
|
533
|
+
query: queryPairs([
|
|
534
|
+
['name', action.name],
|
|
535
|
+
['phone', action.phone],
|
|
536
|
+
['dept_id', action.dept],
|
|
537
|
+
['tenant', normalizeId(flagValue(args, '--tenant'))],
|
|
538
|
+
['include_departed', flagOn(args, '--include-departed') ? 'true' : null],
|
|
539
|
+
['limit', action.limit],
|
|
540
|
+
['offset', action.offset],
|
|
541
|
+
]),
|
|
542
|
+
});
|
|
543
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'GET', '/admin/users/search', spec);
|
|
544
|
+
const response = await runtime.sendAuthed('GET', '/admin/users/search', spec);
|
|
545
|
+
const data = dataOf(response);
|
|
546
|
+
const duplicate = pick(data, 'duplicate_name') === true;
|
|
547
|
+
const nextSteps = [
|
|
548
|
+
'看某人的角色与授权:member --list --user <user_id>',
|
|
549
|
+
'翻页:把响应里的 next_offset 喂给 --offset',
|
|
550
|
+
];
|
|
551
|
+
if (duplicate) {
|
|
552
|
+
nextSteps.push(
|
|
553
|
+
'同名多人:用 --dept <DEPT_ID> 或 --phone <11 位号码> 收窄(每行的 phone_masked 是脱敏号)',
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
runtime.finishFrom(response, {
|
|
557
|
+
members: asArray(pick(data, 'items')),
|
|
558
|
+
total: pick(data, 'total'),
|
|
559
|
+
count: pick(data, 'count'),
|
|
560
|
+
limit: pick(data, 'limit'),
|
|
561
|
+
offset: pick(data, 'offset'),
|
|
562
|
+
has_more: pick(data, 'has_more'),
|
|
563
|
+
next_offset: pick(data, 'next_offset'),
|
|
564
|
+
matched_by: pick(data, 'matched_by'),
|
|
565
|
+
duplicate_name: duplicate,
|
|
566
|
+
order: pick(data, 'order'),
|
|
567
|
+
scope_tenants: pick(data, 'scope_tenants'),
|
|
568
|
+
note: pick(data, 'note'),
|
|
569
|
+
next_steps: nextSteps,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// `--create` / `--role` / `--revoke-role` / `--remove`
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
/** 角色名单校验:不认识的角色本地判死(服务端 `ROLE_TEMPLATES` 是权威,但没必要往返一次)。 */
|
|
578
|
+
function checkRole(role) {
|
|
579
|
+
if (!ROLES.includes(role)) {
|
|
580
|
+
throw CliError.usage(`不认识的角色 ${JSON.stringify(role)}`).withHint(
|
|
581
|
+
`角色只能是:${ROLES.join(' / ')}`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** 赋角的请求体(`--create` 与 `--role` 共用)。 */
|
|
587
|
+
function roleBody(role, tenant, store, reason) {
|
|
588
|
+
return {
|
|
589
|
+
role,
|
|
590
|
+
// 没有企业只对 super_admin / investor 合理(服务端会再判一次)。
|
|
591
|
+
tenants: tenant === null || tenant === undefined ? ['*'] : [tenant],
|
|
592
|
+
store_id: store ?? null,
|
|
593
|
+
reason,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* `member --create`:两步(加白名单 → 赋角),中途失败时**如实报告已经落了哪一步**。
|
|
599
|
+
*
|
|
600
|
+
* 不假装原子:人已经建出来、角没赋上时,报错里带 `identity_created: true` 与 `user_id`,
|
|
601
|
+
* 并给出补赋的那条命令 —— 否则调用方会以为“没建成”而重试,撞上 dup 白名单。
|
|
602
|
+
*/
|
|
603
|
+
async function create(runtime, args) {
|
|
604
|
+
const phone = flagValue(args, '--phone');
|
|
605
|
+
if (phone === null) {
|
|
606
|
+
throw CliError.usage('`member --create` 需要 --phone <手机号>').withHint(
|
|
607
|
+
'例:xinhe-oa-cli member --create --phone 13800000001 --name 小唐 --role tenant_member --tenant kashu',
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
const role = flagValue(args, '--role');
|
|
611
|
+
if (role === null) {
|
|
612
|
+
throw CliError.usage('`member --create` 需要 --role <角色>').withHint(
|
|
613
|
+
`角色只能是:${ROLES.join(' / ')}`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
checkRole(role);
|
|
617
|
+
|
|
618
|
+
// 企业:显式 --tenant > 你的唯一可见企业。super_admin 可以不点名企业。
|
|
619
|
+
// 注意:这一步在 `--dry-run` 里**也会发 GET /me**(Rust 就是先解析企业、再看 dry-run)——
|
|
620
|
+
// 保持逐字一致,而不是“顺手让它一条请求都不发”。
|
|
621
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'));
|
|
622
|
+
if (['tenant_member', 'tenant_owner', 'tenant_operator'].includes(role) && tenant === null) {
|
|
623
|
+
throw CliError.usage(`赋 ${role} 必须指明企业`).withHint(
|
|
624
|
+
'加 --tenant <TENANT_ID>;用 `xinhe-oa-cli tenant --list` 看有哪些企业',
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const reason = flagValue(args, '--reason') ?? `member --create:建人并赋角 ${role}`;
|
|
628
|
+
|
|
629
|
+
// ① 加白名单(顺带建人)
|
|
630
|
+
const identitySpec = requestSpec({
|
|
631
|
+
body: { phone, name: flagValue(args, '--name'), tenant_id: tenant, note: reason },
|
|
632
|
+
});
|
|
633
|
+
const roleSpec = requestSpec({
|
|
634
|
+
body: roleBody(role, tenant, flagValue(args, '--store'), reason),
|
|
635
|
+
});
|
|
636
|
+
if (runtime.dryRun) {
|
|
637
|
+
dryRunReport(runtime, 'POST', '/admin/identities', identitySpec);
|
|
638
|
+
warn('(第二步会接着给这个人赋角)');
|
|
639
|
+
return dryRunReport(runtime, 'POST', '/admin/users/{user_id}/roles', roleSpec);
|
|
640
|
+
}
|
|
641
|
+
const identityResponse = await runtime.sendAuthed('POST', '/admin/identities', identitySpec);
|
|
642
|
+
const identity = dataOf(identityResponse);
|
|
643
|
+
const userId = pick(identity, 'user_id');
|
|
644
|
+
if (typeof userId !== 'string') {
|
|
645
|
+
throw new CliError(
|
|
646
|
+
Kind.Server,
|
|
647
|
+
'cli.bad_response',
|
|
648
|
+
'服务端没有回 user_id(建人失败?)',
|
|
649
|
+
).withRequestId(identityResponse.request_id ?? null);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ② 赋角
|
|
653
|
+
const path = `/admin/users/${userId}/roles`;
|
|
654
|
+
let roleResponse;
|
|
655
|
+
try {
|
|
656
|
+
roleResponse = await runtime.sendAuthed('POST', path, roleSpec);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
// 人已经建出来了、角没赋上:如实说清,并给出补赋的那条命令。
|
|
659
|
+
const tenantFlag = tenant === null ? '' : ` --tenant ${tenant}`;
|
|
660
|
+
if (err instanceof CliError) {
|
|
661
|
+
err.details = {
|
|
662
|
+
identity_created: true,
|
|
663
|
+
identity_id: pick(identity, 'identity_id'),
|
|
664
|
+
user_id: userId,
|
|
665
|
+
server_details: err.details,
|
|
666
|
+
};
|
|
667
|
+
err.hint = `人已经建好了(user_id=${userId}),只是角色没赋上;补赋:xinhe-oa-cli member --role ${role} --user ${userId}${tenantFlag}`;
|
|
668
|
+
throw err;
|
|
669
|
+
}
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
const assigned = dataOf(roleResponse);
|
|
673
|
+
runtime.finishFrom(roleResponse, {
|
|
674
|
+
user_id: userId,
|
|
675
|
+
identity_id: pick(identity, 'identity_id'),
|
|
676
|
+
phone_masked: pick(identity, 'phone_masked'),
|
|
677
|
+
created_user: pick(identity, 'created_user'),
|
|
678
|
+
tenant,
|
|
679
|
+
role,
|
|
680
|
+
grants: pick(assigned, 'grants'),
|
|
681
|
+
actions: pick(assigned, 'actions'),
|
|
682
|
+
// 这里刻意给**完整号码**:`--phone` 是调用方自己敲进来的,而下一步
|
|
683
|
+
// (让本人用短信登录)必须能原样照抄。日志与错误里仍然只有脱敏值。
|
|
684
|
+
next_step: `让这个人自己登录:xinhe-oa-cli login --phone ${phone}(收到验证码后再 --code <码>)`,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** `member --role <ROLE> --user <id>`:给已有成员赋角(响应原样透传)。 */
|
|
689
|
+
async function assign(runtime, args, role, userId) {
|
|
690
|
+
checkRole(role);
|
|
691
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'));
|
|
692
|
+
const reason = flagValue(args, '--reason') ?? `member --role:赋角 ${role}`;
|
|
693
|
+
const spec = requestSpec({
|
|
694
|
+
body: roleBody(role, tenant, flagValue(args, '--store'), reason),
|
|
695
|
+
});
|
|
696
|
+
const path = `/admin/users/${userId}/roles`;
|
|
697
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', path, spec);
|
|
698
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
699
|
+
runtime.finishFrom(response, dataOf(response));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* `member --revoke-role <ROLE> --user <id> --reason <TEXT>`:撤角。
|
|
704
|
+
*
|
|
705
|
+
* `--reason` 必填(>=2 字,写进审计)—— 撤权不留原因,事后没人说得清是谁为什么撤的。
|
|
706
|
+
* 撤角**默认连带吊销该人的会话链**(服务端口径),所以 `result` 是原样透传的。
|
|
707
|
+
*/
|
|
708
|
+
async function revokeRole(runtime, args, role, userId) {
|
|
709
|
+
const reason = flagValue(args, '--reason') ?? '';
|
|
710
|
+
if (charCount(String(reason).trim()) < 2) {
|
|
711
|
+
throw CliError.usage('撤角必须给 --reason(写进审计)').withHint(
|
|
712
|
+
`例:xinhe-oa-cli member --revoke-role ${role} --user ${userId} --reason 离职`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
// `--tenant` 是**只撤这家企业的**那一个角色;这里照 Rust 用原值(不 trim)。
|
|
716
|
+
const tenant = flagValue(args, '--tenant');
|
|
717
|
+
const query = [['reason', String(reason)]];
|
|
718
|
+
if (tenant !== null) query.push(['tenant_id', String(tenant)]);
|
|
719
|
+
const spec = requestSpec({ query });
|
|
720
|
+
const path = `/admin/users/${userId}/roles/${role}`;
|
|
721
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'DELETE', path, spec);
|
|
722
|
+
const response = await runtime.sendAuthed('DELETE', path, spec);
|
|
723
|
+
runtime.finishFrom(response, {
|
|
724
|
+
user_id: userId,
|
|
725
|
+
role,
|
|
726
|
+
result: dataOf(response),
|
|
727
|
+
note: '撤角默认连带吊销该人的会话链(服务端口径)',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* `member --remove <USER_ID> --reason <TEXT>`:撤销白名单号码(移除成员)。
|
|
733
|
+
*
|
|
734
|
+
* 一个人可能有多条**未撤销**的白名单身份(多企业 / 换号),所以要先把它们全找出来
|
|
735
|
+
* 再逐条撤:只撤第一条会留下一个还能 OTP 登录的入口 —— 而调用方以为人已经移除了。
|
|
736
|
+
*/
|
|
737
|
+
async function removeMember(runtime, userId, args) {
|
|
738
|
+
const reason = flagValue(args, '--reason') ?? '';
|
|
739
|
+
if (charCount(String(reason).trim()) < 2) {
|
|
740
|
+
throw CliError.usage('`member --remove` 需要 --reason(写进审计)').withHint(
|
|
741
|
+
'例:xinhe-oa-cli member --remove usr_123 --reason 离职',
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
const response = await runtime.sendAuthed('GET', '/admin/users', requestSpec());
|
|
745
|
+
const item = asArray(pick(dataOf(response), 'items')).find(
|
|
746
|
+
(row) => pick(row, 'user_id') === userId,
|
|
747
|
+
);
|
|
748
|
+
const identities = asArray(item === undefined ? null : pick(item, 'identities')).filter((row) => {
|
|
749
|
+
// `revoked_at` 缺失也算“未撤销”(老后端不带这个字段)。
|
|
750
|
+
const revokedAt = pick(row, 'revoked_at');
|
|
751
|
+
return revokedAt === null;
|
|
752
|
+
});
|
|
753
|
+
if (identities.length === 0) {
|
|
754
|
+
throw CliError.usage(`${userId} 名下没有可撤销的白名单身份`)
|
|
755
|
+
.withDetails({ user_id: userId })
|
|
756
|
+
.withHint('用 `xinhe-oa-cli member --list --json` 核对 user_id');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const revoked = [];
|
|
760
|
+
for (const identity of identities) {
|
|
761
|
+
const identityId = pick(identity, 'id');
|
|
762
|
+
if (typeof identityId !== 'string') continue;
|
|
763
|
+
const spec = requestSpec({ query: [['reason', String(reason)]] });
|
|
764
|
+
const path = `/admin/identities/${identityId}`;
|
|
765
|
+
if (runtime.dryRun) {
|
|
766
|
+
dryRunReport(runtime, 'DELETE', path, spec);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const deleted = await runtime.sendAuthed('DELETE', path, spec);
|
|
770
|
+
revoked.push({ identity_id: identityId, result: dataOf(deleted) });
|
|
771
|
+
}
|
|
772
|
+
if (runtime.dryRun) return;
|
|
773
|
+
// 多请求命令:request_id 取不到单一个,这里刻意给 null(与 Rust 的 finish(out, None, None) 一致)。
|
|
774
|
+
runtime.finish({
|
|
775
|
+
user_id: userId,
|
|
776
|
+
revoked,
|
|
777
|
+
next_step:
|
|
778
|
+
'撤销白名单后该号码不能再走 OTP 登录;用 `member --list --user <id>` 复核他剩下的授权',
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
// 部门归属(ORG-001):set-dept / unset-dept / set-manager
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* `member --set-dept <DEPT_ID> --user <id> [--primary] [--title] [--tenant <t>]`。
|
|
788
|
+
*
|
|
789
|
+
* 首个部门自动为主部门(不带 `--primary` 也是);已有部门时 `--primary`
|
|
790
|
+
* 显式切主部门(旧主部门自动降级,同一事务内完成)。
|
|
791
|
+
*/
|
|
792
|
+
async function setDept(runtime, args, userId, deptId, primary) {
|
|
793
|
+
const body = { dept_id: deptId };
|
|
794
|
+
if (primary) body.primary = true;
|
|
795
|
+
const title = normalizeId(flagValue(args, '--title'));
|
|
796
|
+
if (title !== null) body.title = title;
|
|
797
|
+
const tenant = normalizeId(flagValue(args, '--tenant'));
|
|
798
|
+
if (tenant !== null) body.tenant = tenant;
|
|
799
|
+
const path = `/org/members/${userId}/dept`;
|
|
800
|
+
const spec = requestSpec({ body, idempotencyKey: idempotencyKey('member-set-dept') });
|
|
801
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', path, spec);
|
|
802
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
803
|
+
const data = dataOf(response);
|
|
804
|
+
if (isObject(data)) {
|
|
805
|
+
data.note = '一个人可以同时在多个部门(is_primary 只有一个);撤主部门前要先切新主部门';
|
|
806
|
+
}
|
|
807
|
+
runtime.finishFrom(response, data);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* `member --unset-dept <DEPT_ID> --user <id> [--tenant <t>]`。
|
|
812
|
+
*
|
|
813
|
+
* 撤的是主部门且还有别的部门时,服务端拒绝并要求先显式切主部门
|
|
814
|
+
* (`primary_requires_explicit_new_primary`,details 给 other_dept_ids)——
|
|
815
|
+
* CLI 不替人选新主部门,原样把服务端的 409 交给调用方。
|
|
816
|
+
* 撤到最后一个部门是允许的:“无部门”是合法状态(在职真相在 `user.status`)。
|
|
817
|
+
*/
|
|
818
|
+
async function unsetDept(runtime, args, userId, deptId) {
|
|
819
|
+
const body = { dept_id: deptId };
|
|
820
|
+
const tenant = normalizeId(flagValue(args, '--tenant'));
|
|
821
|
+
if (tenant !== null) body.tenant = tenant;
|
|
822
|
+
const path = `/org/members/${userId}/dept/unset`;
|
|
823
|
+
const spec = requestSpec({ body, idempotencyKey: idempotencyKey('member-unset-dept') });
|
|
824
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', path, spec);
|
|
825
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
826
|
+
runtime.finishFrom(response, dataOf(response));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* `member --set-manager <USER_ID> --user <id>` / `--clear-manager --user <id>`。
|
|
831
|
+
*
|
|
832
|
+
* 直属上级写在**主部门行**上(没有主部门就没有“在哪条线上汇报”);
|
|
833
|
+
* 清空时显式发 `manager_user_id: null` —— 服务端据此区分“清空”与“没给这个键”。
|
|
834
|
+
*/
|
|
835
|
+
async function setManager(runtime, args, userId, manager) {
|
|
836
|
+
const body = { manager_user_id: manager === null ? null : manager };
|
|
837
|
+
const tenant = normalizeId(flagValue(args, '--tenant'));
|
|
838
|
+
if (tenant !== null) body.tenant = tenant;
|
|
839
|
+
const path = `/org/members/${userId}/manager`;
|
|
840
|
+
const spec = requestSpec({ body, idempotencyKey: idempotencyKey('member-set-manager') });
|
|
841
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', path, spec);
|
|
842
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
843
|
+
runtime.finishFrom(response, dataOf(response));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
// ORG-002:授权主体多态(给人 / 给部门授权、按 id 收权、列授权)
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 把 `--resource <type:id>` 拆成服务端的 `resource_type` / `resource_id`。
|
|
852
|
+
*
|
|
853
|
+
* 只按**第一个** `:` 拆:Base 表的 id 自带 `base.<bk>.<tk>` 形态(正好两段点号,
|
|
854
|
+
* 不含冒号),所以 `table:base.contracts.contract` 是 type=table、
|
|
855
|
+
* id=base.contracts.contract。`--dataset <d>` 是等价简写(type 恒为 dataset)。
|
|
856
|
+
*/
|
|
857
|
+
function splitResource(resource) {
|
|
858
|
+
const index = String(resource).indexOf(':');
|
|
859
|
+
if (index < 0) {
|
|
860
|
+
throw CliError.usage(
|
|
861
|
+
`--resource 需要是 <type>:<id> 形态,拿到 ${JSON.stringify(resource)}`,
|
|
862
|
+
).withHint('例:--resource table:base.contracts.contract(type:id,只按第一个冒号拆)');
|
|
863
|
+
}
|
|
864
|
+
const kind = String(resource).slice(0, index).trim();
|
|
865
|
+
const id = String(resource).slice(index + 1).trim();
|
|
866
|
+
if (kind.length === 0 || id.length === 0) {
|
|
867
|
+
throw CliError.usage(
|
|
868
|
+
`--resource 需要是 <type>:<id> 形态,拿到 ${JSON.stringify(resource)}`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
return [kind, id];
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** `member --grant`:授一条资源授权(人主体 / 部门主体二选一,已在上层判死)。 */
|
|
875
|
+
async function grant(runtime, args, action) {
|
|
876
|
+
const resource = flagValue(args, '--resource');
|
|
877
|
+
const dataset = flagValue(args, '--dataset');
|
|
878
|
+
let resourceType;
|
|
879
|
+
let resourceId;
|
|
880
|
+
if (resource !== null) {
|
|
881
|
+
if (dataset !== null) {
|
|
882
|
+
throw CliError.usage('--resource 与 --dataset 只能给一个');
|
|
883
|
+
}
|
|
884
|
+
[resourceType, resourceId] = splitResource(resource);
|
|
885
|
+
} else if (dataset !== null) {
|
|
886
|
+
resourceType = 'dataset';
|
|
887
|
+
resourceId = String(dataset).trim();
|
|
888
|
+
} else {
|
|
889
|
+
throw CliError.usage('`member --grant` 需要 --resource <TYPE:ID>(或 --dataset <数据集>)').withHint(
|
|
890
|
+
'例:--resource table:base.contracts.contract --action read',
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
const actionName = flagValue(args, '--action');
|
|
894
|
+
if (actionName === null) {
|
|
895
|
+
throw CliError.usage('`member --grant` 需要 --action <ACTION>').withHint(
|
|
896
|
+
'动作集与服务端一致:read / write / export / ddl / grant …',
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// `--yes` 闸门(契约 §2:`--grant` 是 high-risk-write)。
|
|
901
|
+
//
|
|
902
|
+
// 位置刻意放在**本地校验之后、发请求之前**:这样"参数写错了"仍然先报参数错
|
|
903
|
+
// (与 Rust 的校验顺序一致),而"参数都对、只是没确认"才落到这里。
|
|
904
|
+
// `--dry-run` 不卡:那一条不落任何写(与 `--offboard --dry-run` 同一个道理)。
|
|
905
|
+
if (!runtime.dryRun) {
|
|
906
|
+
runtime.requireYes(
|
|
907
|
+
'member --grant(给一个人或一个部门加一条资源授权)',
|
|
908
|
+
'xinhe-oa-cli member --grant --user <USER_ID> --resource <TYPE:ID> --action <ACTION> --yes',
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const body = {
|
|
913
|
+
resource_type: resourceType,
|
|
914
|
+
resource_id: resourceId,
|
|
915
|
+
action: actionName,
|
|
916
|
+
note: flagValue(args, '--note'),
|
|
917
|
+
expires_at: flagValue(args, '--expires-at'),
|
|
918
|
+
};
|
|
919
|
+
const tenant = flagValue(args, '--tenant');
|
|
920
|
+
if (action.subjectDept === null) {
|
|
921
|
+
// 人的授权带 `user_id`(与旧路径逐字一致),多一个键都会让老后端困惑。
|
|
922
|
+
body.user_id = action.subjectUser;
|
|
923
|
+
if (tenant !== null) body.tenant_id = tenant;
|
|
924
|
+
} else {
|
|
925
|
+
// 部门主体授权必须落企业:缺省让服务端按“你的唯一可见企业”收敛。
|
|
926
|
+
body.subject_type = 'dept';
|
|
927
|
+
body.subject_id = action.subjectDept;
|
|
928
|
+
if (tenant !== null) body.tenant_id = tenant;
|
|
929
|
+
}
|
|
930
|
+
const store = flagValue(args, '--store');
|
|
931
|
+
if (store !== null) body.store_id = store;
|
|
932
|
+
// ORG-003:部门档位只在 `--dept-scope` 给了的时候才出现(不给 = 请求体里
|
|
933
|
+
// 没有 `dept_scope` 键,行为与今天逐字不变:部门维度不限)。
|
|
934
|
+
if (action.deptScope !== null) {
|
|
935
|
+
body.dept_scope = {
|
|
936
|
+
declaration: action.deptScope.declaration,
|
|
937
|
+
dept_ids: action.deptScope.deptIds,
|
|
938
|
+
include_sub: action.deptScope.includeSub,
|
|
939
|
+
include_unassigned: action.deptScope.includeUnassigned,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const spec = requestSpec({ body, idempotencyKey: idempotencyKey('member-grant') });
|
|
943
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', '/admin/grants', spec);
|
|
944
|
+
const response = await runtime.sendAuthed('POST', '/admin/grants', spec);
|
|
945
|
+
const data = dataOf(response);
|
|
946
|
+
runtime.finishFrom(response, {
|
|
947
|
+
grant_id: pick(data, 'grant_id'),
|
|
948
|
+
subject_type: pick(data, 'subject_type'),
|
|
949
|
+
subject_id: pick(data, 'subject_id'),
|
|
950
|
+
user_id: pick(data, 'user_id'),
|
|
951
|
+
resource_type: pick(data, 'resource_type'),
|
|
952
|
+
resource_id: pick(data, 'resource_id'),
|
|
953
|
+
action: pick(data, 'action'),
|
|
954
|
+
scope: pick(data, 'scope'),
|
|
955
|
+
note: 'high-risk-write 已执行;重复提交同一幂等键不会叠加(服务端幂等)',
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** `member --revoke-grant <GRANT_ID> --reason <TEXT>`:按 id 收权(人 / 部门同一条路径)。 */
|
|
960
|
+
async function revokeGrant(runtime, args, grantId) {
|
|
961
|
+
const reason = flagValue(args, '--reason') ?? '';
|
|
962
|
+
if (charCount(String(reason).trim()) < 2) {
|
|
963
|
+
throw CliError.usage('`member --revoke-grant` 需要 --reason(写进审计)').withHint(
|
|
964
|
+
`例:xinhe-oa-cli member --revoke-grant ${grantId} --reason 调整权限`,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
// `--yes` 闸门(契约 §2:`--revoke-grant` 是 high-risk-write)。
|
|
968
|
+
// 与 `--grant` 同口径:参数错先报参数错,真跑才要确认,`--dry-run` 不卡。
|
|
969
|
+
if (!runtime.dryRun) {
|
|
970
|
+
runtime.requireYes(
|
|
971
|
+
'member --revoke-grant(按 id 收权,人的与部门的走同一条路径)',
|
|
972
|
+
`xinhe-oa-cli member --revoke-grant ${grantId} --reason <原因> --yes`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
const spec = requestSpec({ body: { grant_id: grantId, reason: String(reason) } });
|
|
976
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', '/admin/grants/revoke', spec);
|
|
977
|
+
const response = await runtime.sendAuthed('POST', '/admin/grants/revoke', spec);
|
|
978
|
+
runtime.finishFrom(response, {
|
|
979
|
+
grant_id: grantId,
|
|
980
|
+
result: dataOf(response),
|
|
981
|
+
note: '按 id 收权;部门主体与人主体走同一条路径,权限下一请求即失效',
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** `member --grants [--user <id>] [--dept <id>] [--tenant <t>]`:列有效授权。 */
|
|
986
|
+
async function listGrants(runtime, args, user, dept) {
|
|
987
|
+
const query = [];
|
|
988
|
+
if (user !== null) query.push(['user_id', user]);
|
|
989
|
+
if (dept !== null) {
|
|
990
|
+
// `--dept` 只列该部门主体的行(subject_type=dept + subject_id)。
|
|
991
|
+
query.push(['subject_type', 'dept']);
|
|
992
|
+
query.push(['subject_id', dept]);
|
|
993
|
+
}
|
|
994
|
+
// 照 Rust 用原值(`--tenant` 是筛选词,空串在这里等于“不筛”,交给服务端判)。
|
|
995
|
+
const tenant = flagValue(args, '--tenant');
|
|
996
|
+
if (tenant !== null) query.push(['tenant_id', String(tenant)]);
|
|
997
|
+
const spec = requestSpec({ query });
|
|
998
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'GET', '/admin/grants', spec);
|
|
999
|
+
const response = await runtime.sendAuthed('GET', '/admin/grants', spec);
|
|
1000
|
+
const data = dataOf(response);
|
|
1001
|
+
const grants = asArray(pick(data, 'items')).map((item) => ({
|
|
1002
|
+
grant_id: pick(item, 'id'),
|
|
1003
|
+
subject_type: pick(item, 'subject_type'),
|
|
1004
|
+
subject_id: pick(item, 'subject_id'),
|
|
1005
|
+
user_id: pick(item, 'user_id'),
|
|
1006
|
+
scope: pick(item, 'scope'),
|
|
1007
|
+
resource_type: pick(item, 'resource_type'),
|
|
1008
|
+
resource_id: pick(item, 'resource_id'),
|
|
1009
|
+
action: pick(item, 'action'),
|
|
1010
|
+
expires_at: pick(item, 'expires_at'),
|
|
1011
|
+
revoked_at: pick(item, 'revoked_at'),
|
|
1012
|
+
}));
|
|
1013
|
+
runtime.finishFrom(response, {
|
|
1014
|
+
grants,
|
|
1015
|
+
// 服务端的 `count` 才是“符合条件的总数”(本页条数不是总数)。
|
|
1016
|
+
total: pick(data, 'count'),
|
|
1017
|
+
note: pick(data, 'note'),
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
// ORG-004:离职 / 移交
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
|
|
1025
|
+
/** `--reason` 缺失或太短(<2 字)的用法错误(退出码 2)。 */
|
|
1026
|
+
function reasonUsage(verb, example) {
|
|
1027
|
+
return CliError.usage(`\`member ${verb}\` 需要 --reason(写进审计)`).withHint(`例:${example}`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** 真跑必填的 `--reason`(去空白后 >=2 字)。 */
|
|
1031
|
+
function requiredReason(raw, verb, example) {
|
|
1032
|
+
const value = normalizeId(raw);
|
|
1033
|
+
if (value === null || charCount(value) < 2) throw reasonUsage(verb, example);
|
|
1034
|
+
return value;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* `member --offboard <USER_ID> [--tenant <t>] [--transfer-to <u>] [--scope ...]
|
|
1039
|
+
* [--personal ...] --reason <TEXT> [--dry-run] --yes`。
|
|
1040
|
+
*
|
|
1041
|
+
* **一次事务**完成 状态 / 会话 / 身份 / 部门归属 / 移交(原子性在服务端)。
|
|
1042
|
+
* `--dry-run` 走**同一个端点**(`?dry_run=true`):服务端回同一形状的预演
|
|
1043
|
+
* (`status: "preview"`、各类 `*_revoked: 0`、`transfer.status: "pending"`),
|
|
1044
|
+
* **库里一行都不变** —— 所以它不需要 `--yes`(这一条的风险级别是 `read`,
|
|
1045
|
+
* 契约 §2.1.4 的风险表把它单独列了一行)。
|
|
1046
|
+
*
|
|
1047
|
+
* 同理,`--reason` 在预演里可以不给:预演不写审计,逼人先编一个原因才能看代价,
|
|
1048
|
+
* 只会让“先看看会发生什么”这件事变得昂贵。但**给了就必须合格**(>=2 字)——
|
|
1049
|
+
* 半截原因在预演里被放行、真跑时被拒,那种不一致比直接报错更伤人。
|
|
1050
|
+
*/
|
|
1051
|
+
async function offboard(runtime, args, action) {
|
|
1052
|
+
const dryRun = runtime.dryRun;
|
|
1053
|
+
if (!dryRun) {
|
|
1054
|
+
runtime.requireYes(
|
|
1055
|
+
'member --offboard(一次可能改几十个对象的归属并停用一个人的登录)',
|
|
1056
|
+
`xinhe-oa-cli member --offboard ${action.user} --reason "离职交接" --yes`,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
let reason;
|
|
1060
|
+
if (dryRun) {
|
|
1061
|
+
if (action.reason === null) {
|
|
1062
|
+
reason = null;
|
|
1063
|
+
} else if (charCount(action.reason) >= 2) {
|
|
1064
|
+
reason = action.reason;
|
|
1065
|
+
} else {
|
|
1066
|
+
throw reasonUsage('--offboard', OFFBOARD_EXAMPLE);
|
|
1067
|
+
}
|
|
1068
|
+
} else {
|
|
1069
|
+
reason = requiredReason(action.reason, '--offboard', OFFBOARD_EXAMPLE);
|
|
1070
|
+
}
|
|
1071
|
+
const spec = requestSpec({
|
|
1072
|
+
query: [['dry_run', dryRun ? 'true' : 'false']],
|
|
1073
|
+
body: {
|
|
1074
|
+
tenant: normalizeId(flagValue(args, '--tenant')),
|
|
1075
|
+
transfer_to: action.transferTo,
|
|
1076
|
+
// `--scope` 的缺省已在本地补成 all、`--personal` 补成 transfer:
|
|
1077
|
+
// **永远显式带上这两个键**,免得服务端与 CLI 各有一套缺省。
|
|
1078
|
+
scope: action.scope,
|
|
1079
|
+
personal: action.personal,
|
|
1080
|
+
reason,
|
|
1081
|
+
},
|
|
1082
|
+
// 真跑的请求带幂等键;dry-run 不带(它不写、也不该占幂等口径)。
|
|
1083
|
+
idempotencyKey: dryRun ? null : idempotencyKey('member-offboard'),
|
|
1084
|
+
});
|
|
1085
|
+
const path = `/admin/members/${action.user}/offboard`;
|
|
1086
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1087
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* `member --transfer <FROM_USER_ID> --to <USER_ID> [--tenant <t>] [--scope ...]
|
|
1092
|
+
* --reason <TEXT> --yes`:独立移交(不离职)。
|
|
1093
|
+
*
|
|
1094
|
+
* 同一 `(from_user_id, scope, status='pending')` 重复发起 → 服务端回
|
|
1095
|
+
* `deduped: true` 且**不产生第二批**(退出码 0);CLI 把它显式标出来 ——
|
|
1096
|
+
* 否则调用方看到第二次也“成功”,会以为自己发起了两批。
|
|
1097
|
+
*/
|
|
1098
|
+
async function transfer(runtime, args, action) {
|
|
1099
|
+
runtime.requireYes(
|
|
1100
|
+
'member --transfer(改的是归属,比权限更难挽回)',
|
|
1101
|
+
`xinhe-oa-cli member --transfer ${action.from} --to ${action.to} --reason "岗位调整" --yes`,
|
|
1102
|
+
);
|
|
1103
|
+
// `--dry-run` 也要 `--reason`:预演的是**这次移交**,没有原因就没有可读的预演结果。
|
|
1104
|
+
const reason = requiredReason(action.reason, '--transfer', TRANSFER_EXAMPLE);
|
|
1105
|
+
const spec = requestSpec({
|
|
1106
|
+
body: {
|
|
1107
|
+
tenant: normalizeId(flagValue(args, '--tenant')),
|
|
1108
|
+
from_user_id: action.from,
|
|
1109
|
+
to_user_id: action.to,
|
|
1110
|
+
scope: action.scope,
|
|
1111
|
+
reason,
|
|
1112
|
+
},
|
|
1113
|
+
idempotencyKey: idempotencyKey('member-transfer'),
|
|
1114
|
+
});
|
|
1115
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', '/admin/transfers', spec);
|
|
1116
|
+
const response = await runtime.sendAuthed('POST', '/admin/transfers', spec);
|
|
1117
|
+
const out = dataOf(response);
|
|
1118
|
+
if (isObject(out)) {
|
|
1119
|
+
out.note =
|
|
1120
|
+
out.deduped === true
|
|
1121
|
+
? '同一 (from_user_id, scope, status=pending) 已有待办移交:本次没有产生第二批(deduped: true)'
|
|
1122
|
+
: '移交只改 owner_user_id:新所有者因此获得 can_manage 与 private 档可见性;文件上已有的 ACL 一条不动';
|
|
1123
|
+
}
|
|
1124
|
+
runtime.finishFrom(response, out);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** `member --transfer-list [--from <u>] [--to <u>] [--status ...] [--tenant <t>]`(读)。 */
|
|
1128
|
+
async function transferList(runtime, args, action) {
|
|
1129
|
+
const tenant = normalizeId(flagValue(args, '--tenant'));
|
|
1130
|
+
const spec = requestSpec({
|
|
1131
|
+
query: queryPairs([
|
|
1132
|
+
['from_user_id', action.from],
|
|
1133
|
+
['to_user_id', action.to],
|
|
1134
|
+
['status', action.status],
|
|
1135
|
+
['tenant_id', tenant],
|
|
1136
|
+
]),
|
|
1137
|
+
});
|
|
1138
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'GET', '/admin/transfers', spec);
|
|
1139
|
+
const response = await runtime.sendAuthed('GET', '/admin/transfers', spec);
|
|
1140
|
+
const data = dataOf(response);
|
|
1141
|
+
const items = asArray(hasKey(data, 'transfers') ? data.transfers : pick(data, 'items'));
|
|
1142
|
+
// `transfers` / `total` 的**回退顺序**照抄 Rust 的 `Option::or_else`:
|
|
1143
|
+
// 键存在但值为 null 时**不**回退到 `count`(只回退“键不存在”那种情况)。
|
|
1144
|
+
const total = hasKey(data, 'total')
|
|
1145
|
+
? pick(data, 'total')
|
|
1146
|
+
: hasKey(data, 'count')
|
|
1147
|
+
? pick(data, 'count')
|
|
1148
|
+
: items.length;
|
|
1149
|
+
runtime.finishFrom(response, {
|
|
1150
|
+
transfers: items,
|
|
1151
|
+
total,
|
|
1152
|
+
filter: {
|
|
1153
|
+
from_user_id: action.from,
|
|
1154
|
+
to_user_id: action.to,
|
|
1155
|
+
status: action.status,
|
|
1156
|
+
// 照 Rust 用**原值**回显筛选(与 query 里那个 trim 过的值不是同一个来源)。
|
|
1157
|
+
tenant_id: flagValue(args, '--tenant'),
|
|
1158
|
+
},
|
|
1159
|
+
note: pick(data, 'note'),
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* `member --transfer-revert <TRANSFER_ID> --note <TEXT> [--tenant <t>] --yes`。
|
|
1165
|
+
*
|
|
1166
|
+
* 只改**仍指向 `to_user_id`** 的行;对象在移交后又被转手的逐条跳过
|
|
1167
|
+
* (`skipped[].reason = owner_changed_since_transfer`)。第二次 revert 服务端回
|
|
1168
|
+
* 409 —— 由通用错误映射归到退出码 5,CLI 不做静默成功。
|
|
1169
|
+
*/
|
|
1170
|
+
async function transferRevert(runtime, args, transferId, note) {
|
|
1171
|
+
runtime.requireYes(
|
|
1172
|
+
'member --transfer-revert(把一批对象的归属改回去;已再次转手的逐条跳过)',
|
|
1173
|
+
`xinhe-oa-cli member --transfer-revert ${transferId} --note "应移交给财务" --yes`,
|
|
1174
|
+
);
|
|
1175
|
+
const spec = requestSpec({
|
|
1176
|
+
body: { note, tenant: normalizeId(flagValue(args, '--tenant')) },
|
|
1177
|
+
idempotencyKey: idempotencyKey('member-transfer-revert'),
|
|
1178
|
+
});
|
|
1179
|
+
const path = `/admin/transfers/${transferId}/revert`;
|
|
1180
|
+
if (runtime.dryRun) return dryRunReport(runtime, 'POST', path, spec);
|
|
1181
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1182
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ---------------------------------------------------------------------------
|
|
1186
|
+
// 小工具(与 Rust 侧的语义逐条对齐)
|
|
1187
|
+
// ---------------------------------------------------------------------------
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* 企业缺省值:显式 `--tenant` > 你的唯一可见企业(`GET /me` 的 `scope.tenants`)。
|
|
1191
|
+
*
|
|
1192
|
+
* 多个可见企业时**不猜**(猜错就是给别家企业建人 / 赋角),直接退出码 2
|
|
1193
|
+
* 并把候选列进 `details.visible_tenants` —— 让调用方能照抄一个 `--tenant`。
|
|
1194
|
+
*/
|
|
1195
|
+
async function resolveTenant(runtime, requested) {
|
|
1196
|
+
const explicit = normalizeId(requested);
|
|
1197
|
+
if (explicit !== null) return explicit;
|
|
1198
|
+
const response = await runtime.sendAuthed('GET', '/me', requestSpec());
|
|
1199
|
+
const tenants = asArray(pick(pick(dataOf(response), 'scope'), 'tenants'));
|
|
1200
|
+
if (tenants.length === 1) return pick(tenants[0], 'id');
|
|
1201
|
+
if (tenants.length === 0) return null;
|
|
1202
|
+
throw CliError.usage('你有多个可见企业,请用 --tenant <TENANT_ID> 指明').withDetails({
|
|
1203
|
+
visible_tenants: tenants,
|
|
1204
|
+
hint: 'xinhe-oa-cli tenant --list',
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* 解析 `--dept-scope`(大小写不敏感;`CUSTOM` → 服务端 `CUSTOM_DEPTS`)。
|
|
1210
|
+
*
|
|
1211
|
+
* `CUSTOM` 没有 `--dept-id` 是**用法错误**(退出码 2、不发请求):服务端对
|
|
1212
|
+
* “声明 CUSTOM_DEPTS 但没给 ID”回 400,而客户端完全能提前看出来 ——
|
|
1213
|
+
* 一次注定失败的往返不该发生(契约 §2 把这条钉成了本地判定)。
|
|
1214
|
+
*/
|
|
1215
|
+
function parseDeptScope(raw, deptIds, includeSub, includeUnassigned) {
|
|
1216
|
+
// 只做大写化,且只认 ASCII(对齐 Rust `to_ascii_uppercase`:
|
|
1217
|
+
// 非 ASCII 字符的“大写”在各语言里语义不同,不能拿 JS 的 toUpperCase 顶替)。
|
|
1218
|
+
const upper = String(raw).trim().replace(/[a-z]/g, (ch) => ch.toUpperCase());
|
|
1219
|
+
let declaration;
|
|
1220
|
+
if (upper === 'ALL') declaration = 'ALL';
|
|
1221
|
+
else if (upper === 'DEPT') declaration = 'DEPT';
|
|
1222
|
+
else if (upper === 'DEPT_AND_SUB') declaration = 'DEPT_AND_SUB';
|
|
1223
|
+
else if (upper === 'CUSTOM' || upper === 'CUSTOM_DEPTS') declaration = 'CUSTOM_DEPTS';
|
|
1224
|
+
else {
|
|
1225
|
+
throw CliError.usage(`不认识的 --dept-scope ${JSON.stringify(upper)}`).withHint(
|
|
1226
|
+
'档位只有:ALL / DEPT / DEPT_AND_SUB / CUSTOM',
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
const ids = deptIds.map((value) => String(value).trim()).filter((value) => value.length > 0);
|
|
1230
|
+
if (declaration === 'CUSTOM_DEPTS' && ids.length === 0) {
|
|
1231
|
+
throw CliError.usage('`--dept-scope CUSTOM` 至少需要一个 --dept-id <DEPT_ID>').withHint(
|
|
1232
|
+
'例:--dept-scope CUSTOM --dept-id ou_1 --dept-id ou_2',
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
return { declaration, deptIds: ids, includeSub, includeUnassigned };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** ORG-004:`--scope` 的枚举(缺省 `all`)。 */
|
|
1239
|
+
function parseTransferScope(raw) {
|
|
1240
|
+
const value = normalizeId(raw) ?? 'all';
|
|
1241
|
+
// 只折 ASCII(对齐 Rust `to_ascii_lowercase`):非 ASCII 的“小写”各语言语义不同。
|
|
1242
|
+
const lowered = value.replace(/[A-Z]/g, (ch) => ch.toLowerCase());
|
|
1243
|
+
if (lowered === 'attachment' || lowered === 'skill' || lowered === 'all') return lowered;
|
|
1244
|
+
throw CliError.usage(`不认识的 --scope ${JSON.stringify(lowered)}`).withHint(
|
|
1245
|
+
'范围只有:attachment / skill / all(缺省 all)',
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** ORG-004:`--personal` 的枚举(缺省 `transfer`;`keep` 是**显式**选项)。 */
|
|
1250
|
+
function parsePersonal(raw) {
|
|
1251
|
+
const value = normalizeId(raw) ?? 'transfer';
|
|
1252
|
+
// 只折 ASCII(对齐 Rust `to_ascii_lowercase`):非 ASCII 的“小写”各语言语义不同。
|
|
1253
|
+
const lowered = value.replace(/[A-Z]/g, (ch) => ch.toLowerCase());
|
|
1254
|
+
if (lowered === 'keep' || lowered === 'transfer') return lowered;
|
|
1255
|
+
throw CliError.usage(`不认识的 --personal ${JSON.stringify(lowered)}`).withHint(
|
|
1256
|
+
'个人文件处置只有:keep / transfer(缺省 transfer)',
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** ORG-004:`--transfer-list --status` 的枚举(缺省 = 不过滤)。 */
|
|
1261
|
+
function parseTransferStatus(raw) {
|
|
1262
|
+
const value = normalizeId(raw);
|
|
1263
|
+
if (value === null) return null;
|
|
1264
|
+
// 只折 ASCII(对齐 Rust `to_ascii_lowercase`):非 ASCII 的“小写”各语言语义不同。
|
|
1265
|
+
const lowered = value.replace(/[A-Z]/g, (ch) => ch.toLowerCase());
|
|
1266
|
+
if (['pending', 'applied', 'reverted', 'cancelled'].includes(lowered)) return lowered;
|
|
1267
|
+
throw CliError.usage(`不认识的 --status ${JSON.stringify(lowered)}`).withHint(
|
|
1268
|
+
'移交状态只有:pending / applied / reverted / cancelled(缺省不过滤)',
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** 去空白后为空一律当“没给”(空串不是筛选条件,也不是目标)。 */
|
|
1273
|
+
function normalizeId(raw) {
|
|
1274
|
+
if (raw === null || raw === undefined) return null;
|
|
1275
|
+
const text = String(raw).trim();
|
|
1276
|
+
return text.length > 0 ? text : null;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** 按**码点**计数(对齐 Rust `chars().count()`,否则 emoji/生僻字会被算多)。 */
|
|
1280
|
+
function charCount(text) {
|
|
1281
|
+
return Array.from(String(text)).length;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* `member --search --phone` 的本地校验:**只认精确号码**(11 位、1 开头)。
|
|
1286
|
+
*
|
|
1287
|
+
* 规范化(`+86` / 空格 / 横线)交给服务端 `normalize_phone` —— 这里只回答
|
|
1288
|
+
* “这是不是一个号码”,因为检索**不做前缀 / 号段 / 模糊匹配**:号码是登录凭据本身,
|
|
1289
|
+
* 开放模糊等于把全平台用户目录变成可探测面(MEMBER-001 §5.3)。
|
|
1290
|
+
*/
|
|
1291
|
+
function isExactPhone(raw) {
|
|
1292
|
+
const digits = Array.from(String(raw))
|
|
1293
|
+
.filter((ch) => ch >= '0' && ch <= '9')
|
|
1294
|
+
.join('');
|
|
1295
|
+
const national =
|
|
1296
|
+
digits.startsWith('86') && digits.length - 2 === 11 ? digits.slice(2) : digits;
|
|
1297
|
+
return national.length === 11 && national.startsWith('1');
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/** `--dry-run`:把将发送的单个请求打印出来(**不发**)。 */
|
|
1301
|
+
function dryRunReport(runtime, method, path, spec) {
|
|
1302
|
+
runtime.finish({
|
|
1303
|
+
dry_run: true,
|
|
1304
|
+
request: dryRunRequestOf(runtime, method, path, spec),
|
|
1305
|
+
note: '--dry-run:没有发出任何请求,也没有写任何东西',
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* 单个请求的 dry-run 描述。
|
|
1311
|
+
*
|
|
1312
|
+
* `multipart` 这一栏**必须显式给 null**(没有附件时也一样):Rust 侧的
|
|
1313
|
+
* `serde_json` 对象恒带这个键,少一个键会让 `--dry-run` 的 `--json` 与 Rust 版
|
|
1314
|
+
* 差一行 —— 而 `--dry-run` 的输出正是判卷拿来对齐“将发什么请求”的依据。
|
|
1315
|
+
*/
|
|
1316
|
+
function dryRunRequestOf(runtime, method, path, spec) {
|
|
1317
|
+
const request = runtime.dryRunRequest(method, path, spec);
|
|
1318
|
+
if (!Object.prototype.hasOwnProperty.call(request, 'multipart')) request.multipart = null;
|
|
1319
|
+
return request;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Rust `Value::get` 的等价物:非对象 / 缺键都给 null(键必须在、值为 null)。 */
|
|
1323
|
+
function pick(value, key) {
|
|
1324
|
+
if (!isObject(value)) return null;
|
|
1325
|
+
const found = value[key];
|
|
1326
|
+
return found === undefined ? null : found;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function isObject(value) {
|
|
1330
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** 键**存在**的判定(Rust `Value::get` 是 Option:存在但为 null 与缺键不是一回事)。 */
|
|
1334
|
+
function hasKey(value, key) {
|
|
1335
|
+
return isObject(value) && Object.prototype.hasOwnProperty.call(value, key);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Rust `Value::as_array` 的等价物:不是数组就是空数组。 */
|
|
1339
|
+
function asArray(value) {
|
|
1340
|
+
return Array.isArray(value) ? value : [];
|
|
1341
|
+
}
|