weaver-work-cli 0.1.22 → 0.1.24

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.
Files changed (57) hide show
  1. package/README.md +23 -2
  2. package/dist/internal/e10/auth/commands.js +194 -16
  3. package/dist/internal/e10/auth/xiaoe.js +15 -6
  4. package/dist/internal/e10/context.js +3 -3
  5. package/dist/internal/e10/request-runtime.js +46 -4
  6. package/dist/shortcuts/caizhilian/file-upload/metadata.js +124 -0
  7. package/dist/shortcuts/caizhilian/file-upload/multipart.js +47 -0
  8. package/dist/shortcuts/caizhilian/file-upload/protocol.js +116 -0
  9. package/dist/shortcuts/caizhilian/file-upload/receipt.js +37 -0
  10. package/dist/shortcuts/caizhilian/host.js +13 -5
  11. package/dist/shortcuts/caizhilian/operations/handlers/file-upload.js +72 -179
  12. package/dist/shortcuts/caizhilian/operations/handlers/folder.js +701 -4
  13. package/dist/shortcuts/caizhilian/operations/registry.js +5 -1
  14. package/dist/shortcuts/caizhilian/operations/specs.js +52 -0
  15. package/dist/shortcuts/meeting/operations/change.js +7 -4
  16. package/dist/shortcuts/meeting/operations/create.js +5 -3
  17. package/dist/shortcuts/meeting/operations/eb/decision.js +22 -2
  18. package/dist/shortcuts/meeting/operations/lifecycle.js +8 -6
  19. package/dist/shortcuts/meeting/operations/receipts.js +54 -21
  20. package/dist/shortcuts/meeting/operations/shared.js +28 -0
  21. package/dist/shortcuts/meeting/operations/signs.js +14 -4
  22. package/dist/shortcuts/project/operations/read.js +7 -6
  23. package/dist/shortcuts/workflow/continuation.js +14 -9
  24. package/dist/shortcuts/workflow/endpoints.js +11 -1
  25. package/dist/shortcuts/workflow/operations/flow-status.js +63 -0
  26. package/dist/shortcuts/workflow/operations/form.js +650 -518
  27. package/dist/shortcuts/workflow/operations/read.js +374 -13
  28. package/dist/shortcuts/workflow/operations/shared.js +3 -0
  29. package/dist/shortcuts/workflow/operations/write.js +5 -5
  30. package/dist/shortcuts/workflow/operations.js +8 -6
  31. package/docs/caizhilian.md +8 -2
  32. package/docs/e10-auth.md +18 -3
  33. package/package.json +1 -1
  34. package/skills/weaver-e10-caizhilian/SKILL.md +8 -3
  35. package/skills/weaver-e10-caizhilian/references/file-upload.md +100 -295
  36. package/skills/weaver-e10-caizhilian/references/folder.md +31 -3
  37. package/skills/weaver-e10-caizhilian/references/source-manifest.json +536 -25
  38. package/skills/weaver-e10-jiuchuanhui/SKILL.md +9 -1
  39. package/skills/weaver-e10-meeting/SKILL.md +3 -2
  40. package/skills/weaver-e10-meeting/references/meeting-eb.md +10 -0
  41. package/skills/weaver-e10-meeting/references/meeting-receipts-signs.md +7 -2
  42. package/skills/weaver-e10-meeting/references/meeting-verification.md +1 -1
  43. package/skills/weaver-e10-meeting.zip +0 -0
  44. package/skills/weaver-e10-shared/SKILL.md +12 -2
  45. package/skills/weaver-e10-shared/references/e10-auth-and-session.md +21 -3
  46. package/skills/weaver-e10-shijingran/SKILL.md +68 -3
  47. package/skills/weaver-e10-shijingran/references/disabled-capabilities.md +22 -0
  48. package/skills/weaver-e10-shijingran/references/source-manifest.json +12 -4
  49. package/skills/weaver-e10-workflow/SKILL.md +10 -8
  50. package/skills/weaver-e10-workflow/references/source-manifest.json +36 -31
  51. package/skills/weaver-e10-workflow/references/workflow-batch.md +2 -4
  52. package/skills/weaver-e10-workflow/references/workflow-create.md +58 -36
  53. package/skills/weaver-e10-workflow/references/workflow-edit.md +2 -1
  54. package/skills/weaver-e10-workflow/references/workflow-memory.md +2 -2
  55. package/skills/weaver-e10-workflow/references/workflow-operate.md +2 -1
  56. package/skills/weaver-e10-workflow/references/workflow-query.md +9 -7
  57. package/skills/weaver-e10-workflow/references/workflow-share.md +2 -0
@@ -65,7 +65,10 @@ function prepareDefinition(spec, entry) {
65
65
  summary: `${spec.summary}|prepare:只读校验与请求体预览,签发 apply 所需的 continuation,不产生副作用`,
66
66
  risk: 'read-before-write',
67
67
  requiresConfirmation: true,
68
- handler: (args, env) => entry.prepare(args, env),
68
+ handler: (args, env) => {
69
+ entry.validateInput?.(args);
70
+ return entry.prepare(args, env);
71
+ },
69
72
  };
70
73
  }
71
74
  /**
@@ -83,6 +86,7 @@ function applyDefinition(spec, entry) {
83
86
  requiresConfirmation: true,
84
87
  inputSchema: applySchemaOf(spec.inputSchema, base.required ?? []),
85
88
  handler: async (args, env) => {
89
+ entry.validateInput?.(args);
86
90
  const lease = await verifyLease(args, name, env);
87
91
  assertLeaseArgs(lease, args, name);
88
92
  assertLeaseTarget(lease, args, entry.targetKeys ?? [], name);
@@ -271,6 +271,8 @@ export const caizhilianOperationSpecs = [
271
271
  position_attribute: LIST('岗位属性'),
272
272
  position_level_department: LIST('岗位层级-部门'),
273
273
  position_level_subcompany: LIST('岗位层级-分部'),
274
+ seclevel_min: INT('密级下限,范围 -999~999', { minimum: -999, maximum: 999 }),
275
+ seclevel_max: INT('密级上限,范围 -999~999', { minimum: -999, maximum: 999 }),
274
276
  }, ['folder_id', 'permission_type', 'obj_type'], { requiresConfirmation: true }),
275
277
  spec('folder', 'permission.delete', 'delete_folder_permission', 'high-risk-write', '删除文件夹文档权限设置', {
276
278
  folder_id: ID('文件夹 ID'),
@@ -309,6 +311,50 @@ export const caizhilianOperationSpecs = [
309
311
  spec('folder', 'permission.sync-setting', 'sync_folder_setting_permission', 'high-risk-write', '将当前文件夹的"管理员"权限设置同步到下级子文件夹', {
310
312
  id: ID('文件夹 ID'),
311
313
  keepOldSettingForAllFolders: BOOL('是否保留原有管理员设置(true=保留,false=不保留)'),
314
+ }, ['id', 'keepOldSettingForAllFolders'], { requiresConfirmation: true }),
315
+ spec('folder', 'permission.sync-maintain', 'sync_folder_maintain_permission', 'high-risk-write', '将当前文件夹的"文件夹维护"权限设置同步到下级子文件夹', {
316
+ id: ID('文件夹 ID'),
317
+ keepOldSettingForAllFolders: BOOL('是否保留原有文件夹维护设置(true=保留,false=不保留)'),
318
+ }, ['id', 'keepOldSettingForAllFolders'], { requiresConfirmation: true }),
319
+ spec('folder', 'permission.sync-download', 'sync_folder_download_permission', 'high-risk-write', '将当前文件夹的"下载权限"设置同步到下级子文件夹', {
320
+ id: ID('文件夹 ID'),
321
+ keepOldSettingForAllFolders: BOOL('是否保留原有下载权限设置(true=保留,false=不保留)'),
322
+ }, ['id'], { requiresConfirmation: true }),
323
+ spec('folder', 'permission.sync-delete', 'sync_folder_delete_permission', 'high-risk-write', '将当前文件夹的"删除权限"设置同步到下级子文件夹', {
324
+ id: ID('文件夹 ID'),
325
+ keepOldSettingForAllFolders: BOOL('是否保留原有删除权限设置(true=保留,false=不保留)'),
326
+ }, ['id'], { requiresConfirmation: true }),
327
+ spec('folder', 'permission.sync-copy', 'sync_folder_copy_permission', 'high-risk-write', '将当前文件夹的"复制权限"设置同步到下级子文件夹', {
328
+ id: ID('文件夹 ID'),
329
+ keepOldSettingForAllFolders: BOOL('是否保留原有复制权限设置(true=保留,false=不保留)'),
330
+ }, ['id', 'keepOldSettingForAllFolders'], { requiresConfirmation: true }),
331
+ spec('folder', 'permission.sync-create', 'sync_folder_create_permission', 'high-risk-write', '将当前文件夹的"新建文档权限"设置同步到下级子文件夹', {
332
+ id: ID('文件夹 ID'),
333
+ keepOldSettingForAllFolders: BOOL('是否保留原有新建文档权限设置(true=保留,false=不保留)'),
334
+ }, ['id', 'keepOldSettingForAllFolders'], { requiresConfirmation: true }),
335
+ spec('folder', 'permission.sync-move', 'sync_folder_move_permission', 'high-risk-write', '将当前文件夹的"移动权限"设置同步到下级子文件夹', {
336
+ id: ID('文件夹 ID'),
337
+ keepOldSettingForAllFolders: BOOL('是否保留原有移动权限设置(true=保留,false=不保留)'),
338
+ }, ['id', 'keepOldSettingForAllFolders'], { requiresConfirmation: true }),
339
+ spec('folder', 'permission.sync-participant', 'sync_folder_participant', 'high-risk-write', '将当前文件夹的"默认参与人"设置同步到下级子文件夹或历史文档', {
340
+ id: ID('文件夹 ID'),
341
+ sync_to_folder_docs: BOOL('是否同步到当前文件夹内的历史文档'),
342
+ sync_to_all_folders: BOOL('是否同步到所有下级文件夹'),
343
+ sync_to_all_sub_folder_docs: BOOL('是否同步到所有下级文件夹内的历史文档'),
344
+ keep_old_for_folder_docs: BOOL('原有参与人是否保留(同步到历史文档时)', { default: true }),
345
+ keep_old_for_all_folders: BOOL('原有参与人是否保留(同步到下级文件夹时)', { default: true }),
346
+ keep_old_for_all_sub_folder_docs: BOOL('原有参与人是否保留(同步到下级文件夹历史文档时)', { default: true }),
347
+ }, ['id'], { requiresConfirmation: true }),
348
+ spec('folder', 'permission.sync-default-share', 'sync_folder_default_share', 'high-risk-write', '将当前文件夹的"默认共享人"设置同步到下级子文件夹或历史文档', {
349
+ id: ID('文件夹 ID'),
350
+ sync_to_folder_docs: BOOL('是否同步到当前文件夹内的历史文档'),
351
+ sync_to_all_folders: BOOL('是否同步到所有下级文件夹'),
352
+ sync_to_all_sub_folder_docs: BOOL('是否同步到所有下级文件夹内的历史文档'),
353
+ keep_old_default_share_for_folder_docs: BOOL('原有默认共享人是否保留(同步到历史文档时)', { default: true }),
354
+ keep_old_non_default_share_for_folder_docs: BOOL('原有非默认共享人是否保留(同步到历史文档时)', { default: true }),
355
+ keep_old_default_share_for_all_folders: BOOL('原有默认共享人是否保留(同步到下级文件夹时)', { default: true }),
356
+ keep_old_default_share_for_sub_folder_docs: BOOL('原有默认共享人是否保留(同步到下级文件夹历史文档时)', { default: true }),
357
+ keep_old_non_default_share_for_sub_folder_docs: BOOL('原有非默认共享人是否保留(同步到下级文件夹历史文档时)', { default: true }),
312
358
  }, ['id'], { requiresConfirmation: true }),
313
359
  // ── setting-attach ──────────────────────────────────────
314
360
  spec('setting-attach', 'get', 'get_setting_attach', 'read', '读取附件设置(文件设置 → 附件设置)', {}, [], {
@@ -544,11 +590,17 @@ export const caizhilianOperationSpecs = [
544
590
  spec('file-upload', 'prepare', 'prepare_upload', 'read', '准备上传(本地文件校验与元数据)', {
545
591
  file_path: FILE('本地文件绝对路径'),
546
592
  module: S('业务模块(如 document、workflow 等)'),
593
+ context_file: FILE('可选业务上下文 JSON 文件绝对路径(最大 64 KiB)'),
594
+ chat_id: S('宿主对话标识,用于隔离上传回执;默认 cli'),
595
+ timeout_seconds: INT('上传超时秒数(1–3600,默认 300)', { minimum: 1, maximum: 3600, default: 300 }),
547
596
  }, ['file_path', 'module']),
548
597
  spec('file-upload', 'upload', 'upload_file', 'high-risk-write', '上传文件到文件服务', {
549
598
  file_path: FILE('本地文件绝对路径'),
550
599
  module: S('业务模块(如 document、workflow 等)'),
551
600
  user_confirmed: BOOL('用户已确认上传'),
601
+ context_file: FILE('可选业务上下文 JSON 文件绝对路径(最大 64 KiB)'),
602
+ chat_id: S('宿主对话标识,用于隔离上传回执;默认 cli'),
603
+ timeout_seconds: INT('上传超时秒数(1–3600,默认 300)', { minimum: 1, maximum: 3600, default: 300 }),
552
604
  }, ['file_path', 'module', 'user_confirmed'], { requiresConfirmation: true }),
553
605
  // ── org ─────────────────────────────────────────────────
554
606
  spec('org', 'search-user', 'search_user', 'read', '人员浏览器检索(源 search_user → /api/hrm/common/browser/data/resource)', {
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { meetingConfirmationError, meetingPartialError, meetingValidationError } from '../errors.js';
3
3
  import { meetingSuccess, SCHEMA_DATETIME_MINUTE, SCHEMA_ID } from './types.js';
4
- import { assertDateTimeMinute, extractRows, toIdCsv, toIdString } from './shared.js';
4
+ import { assertDateTimeMinute, extractRows, mergeSnapshot, toIdCsv, toIdString } from './shared.js';
5
5
  const CONTINUATION_TTL_SECONDS = 600;
6
6
  const LIST_PATH = '/api/meeting/search/getMeetingList';
7
7
  function fingerprint(value) {
@@ -202,15 +202,18 @@ const meetingChangeApply = {
202
202
  caller: SCHEMA_ID,
203
203
  contacter: SCHEMA_ID,
204
204
  },
205
- required: ['confirm', 'continuation', 'id'],
205
+ required: ['confirm', 'continuation'],
206
206
  },
207
207
  handler: async (args, env) => {
208
208
  requireConfirm(args, 'meeting.change.apply');
209
209
  const lease = await verifyLease(args, 'meeting.change.apply', env);
210
- const body = normalizeChangeInput(args);
210
+ // 只传 confirm+continuation 时用 prepare 快照(含 prepare 时算好的 id / 字段);
211
+ // 传入字段则覆盖快照,仍由 fingerprint 决定是否 target_changed。
212
+ const merged = mergeSnapshot(lease.payload, args);
213
+ const { operateType: leaseOperateType, ...mergedBody } = merged;
214
+ const body = normalizeChangeInput(mergedBody);
211
215
  const id = String(body.id);
212
216
  const { operateType, mtStatus } = await resolveOperateType(id, env, 'meeting.change.apply');
213
- const leaseOperateType = lease.payload?.operateType;
214
217
  if (leaseOperateType && leaseOperateType !== operateType) {
215
218
  throw meetingValidationError('target_changed', `会议状态已变化(prepare 时按 ${leaseOperateType}、当前按 ${operateType},当前状态:${mtStatus}),请重新执行 prepare 并向用户确认`);
216
219
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { meetingConflictError, meetingConfirmationError, meetingPartialError, meetingValidationError } from '../errors.js';
3
3
  import { meetingSuccess, SCHEMA_DATETIME_MINUTE, SCHEMA_ID } from './types.js';
4
- import { assertDateTimeMinute, extractConflict, toIdCsv, toIdString, } from './shared.js';
4
+ import { assertDateTimeMinute, extractConflict, mergeSnapshot, toIdCsv, toIdString, } from './shared.js';
5
5
  const CONTINUATION_TTL_SECONDS = 600;
6
6
  function fingerprint(value) {
7
7
  return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex').slice(0, 32);
@@ -309,13 +309,15 @@ const meetingCreateApply = {
309
309
  repeatDesc: { type: 'string' },
310
310
  serviceItems: { type: 'string' },
311
311
  },
312
- required: ['confirm', 'continuation', 'name', 'beginDatetime', 'endDatetime'],
312
+ required: ['confirm', 'continuation'],
313
313
  },
314
314
  handler: async (args, env) => {
315
315
  requireConfirm(args, 'meeting.create.apply');
316
316
  const lease = await verifyLease(args, 'meeting.create.apply', env);
317
317
  const session = await env.host.getSession();
318
- const body = normalizeCreateInput(args, session.userId);
318
+ // 只传 confirm+continuation 时用 prepare 快照(用户已确认的内容);传入其它字段则作为覆盖,
319
+ // 由下面的 fingerprint 校验决定是否 target_changed。
320
+ const body = normalizeCreateInput(mergeSnapshot(lease.payload, args), session.userId);
319
321
  if (lease.fingerprint !== fingerprint(body)) {
320
322
  throw meetingValidationError('target_changed', '会议内容与 prepare 时的快照不一致,请重新执行 prepare');
321
323
  }
@@ -21,6 +21,13 @@ import { meetingPartialError, meetingValidationError } from '../../errors.js';
21
21
  import { toIdString } from '../shared.js';
22
22
  const CONTINUATION_TTL_SECONDS = 600;
23
23
  const DATE_PATTERN = '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$';
24
+ /** 本地时间的 yyyy-MM-dd HH:mm:ss(EB 的签到/完成时间字段要求带秒)。 */
25
+ function formatSecondNow() {
26
+ const now = new Date();
27
+ const pad = (value) => String(value).padStart(2, '0');
28
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} `
29
+ + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
30
+ }
24
31
  /** 决议状态:0未决议 / 1决议临时保存 / 2决议提交完成。 */
25
32
  const DECISION_DONE = '2';
26
33
  /** 会议状态「结束」:产生决议后会议即视为结束。 */
@@ -293,7 +300,19 @@ const signMarkPrepare = {
293
300
  throw meetingValidationError('sign_record_not_found', `未在该会议(${mtId})中找到员工 ${userId} 的签到记录,无法补签。`
294
301
  + '请先用 meeting.eb.sign.list 确认该会议是否已生成签到数据(若会议尚无签到记录,属建会初始化问题,需先用 meeting.eb.sign.create 初始化后再补签)。', { mtId, userId });
295
302
  }
296
- const update = normalizeEbWrite(requireEndpoint('update_meeting_sign_info'), { ...args, id: recordId, mt_id: mtId }, session.userId);
303
+ // ⚠️ 2026-10-08 真机实测:只传 mt_id + user_id 时,update 里**没有任何时间字段**,
304
+ // 服务端照常返回成功、CLI 报 COMPLETE,但签到记录**一点没变**(静默无效)。
305
+ // 补签的语义就是"把签到时间补录为当前时间",故缺省补当前时间,并明确告警;
306
+ // 补签退同理(只传 user_id 而不给 signback_time 时补当前时间)。
307
+ const warnings = [];
308
+ const patch = { ...args, id: recordId, mt_id: mtId };
309
+ if (patch.sign_time === undefined && patch.signback_time === undefined && patch.sign_out_status === undefined) {
310
+ const now = formatSecondNow();
311
+ patch.sign_time = now;
312
+ patch.is_sign = patch.is_sign ?? '1';
313
+ warnings.push(`未传签到时间:按"补签"语义使用当前时间 ${now}(如需指定时间请传 sign_time)`);
314
+ }
315
+ const update = normalizeEbWrite(requireEndpoint('update_meeting_sign_info'), patch, session.userId);
297
316
  const payload = { mtId, signRecordId: recordId, update };
298
317
  const token = await env.continuation.issue({
299
318
  operation: 'meeting.eb.sign.mark.apply',
@@ -316,10 +335,11 @@ const signMarkPrepare = {
316
335
  continuation: token,
317
336
  expiresInSeconds: CONTINUATION_TTL_SECONDS,
318
337
  }, {
338
+ warnings,
319
339
  meta: {
320
340
  notes: [
321
341
  '走的是**更新**(update_meeting_sign_info),不会新增签到记录。',
322
- '未传 sign_time 时签到时间仍为空,请按需补上。',
342
+ '未传 sign_time / signback_time 时视为「补签」,由 CLI 用当前时间补上(preview.after 里可见实际写入值)。',
323
343
  ],
324
344
  },
325
345
  });
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { meetingConfirmationError, meetingPartialError, meetingValidationError } from '../errors.js';
3
3
  import { meetingSuccess, SCHEMA_ID } from './types.js';
4
- import { assertMeetingExists, toIdString } from './shared.js';
4
+ import { assertMeetingExists, mergeSnapshot, toIdString } from './shared.js';
5
5
  const CONTINUATION_TTL_SECONDS = 600;
6
6
  function fingerprint(value) {
7
7
  return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex').slice(0, 32);
@@ -84,14 +84,16 @@ function lifecyclePair(spec) {
84
84
  properties: {
85
85
  confirm: { const: true },
86
86
  continuation: { type: 'string', minLength: 1 },
87
- id: { ...SCHEMA_ID, description: '会议 id(必须与 prepare 时一致)' },
87
+ id: { ...SCHEMA_ID, description: '会议 id(可选:省略时取 prepare 快照中的 id)' },
88
88
  },
89
- required: ['confirm', 'continuation', 'id'],
89
+ required: ['confirm', 'continuation'],
90
90
  },
91
91
  handler: async (args, env) => {
92
92
  requireConfirm(args, applyName);
93
93
  const lease = await verifyLease(args, applyName, env);
94
- const id = toIdString(args.id, 'id');
94
+ // 只传 confirm+continuation 时用 prepare 快照里的 id(与 create/change 的 apply 行为一致)。
95
+ const merged = mergeSnapshot(lease.payload, args);
96
+ const id = toIdString(merged.id, 'id');
95
97
  if (lease.fingerprint !== fingerprint({ id })) {
96
98
  throw meetingValidationError('target_changed', '会议 id 与 prepare 时的快照不一致,请重新执行 prepare');
97
99
  }
@@ -129,11 +131,11 @@ export const meetingLifecycleOperations = [
129
131
  ...lifecyclePair({
130
132
  action: 'delete',
131
133
  resource: 'meeting',
132
- summaryPrepare: '准备删除会议记录:回查会议存在性并生成确认预览,不写入(彻底删除)',
134
+ summaryPrepare: '准备删除会议记录:回查会议存在性并生成确认预览,不写入(软删:进回收站,可还原)',
133
135
  summaryApply: '提交删除会议记录(deleteMeetingDetail,源码为表单绑定,参数走 query);必须在用户明确确认后调用',
134
136
  path: '/api/meeting/detail/deleteMeetingDetail',
135
137
  via: 'query',
136
138
  bodyOf: (id) => ({ id }),
137
- note: '删除是彻底移除会议记录(区别于取消);不可逆,需用户明确确认。服务端会按会议状态判定删除权限,无权限时返回“您没有删除权限!”。',
139
+ note: '删除是**软删**(服务端 update mt_detail set delete_type=2):会议移入回收站、可从回收站还原,正常列表中不再出现;需要还原请引导用户到会议回收站页面操作(CLI 暂无还原能力)。服务端会按会议状态判定删除权限,无权限时返回“您没有删除权限!”。',
138
140
  }),
139
141
  ];
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { meetingPartialError, meetingValidationError } from '../errors.js';
3
3
  import { meetingSuccess, SCHEMA_ID } from './types.js';
4
- import { extractRows, toIdString } from './shared.js';
4
+ import { extractRows, mergeSnapshot, pickBusinessInput, toIdString } from './shared.js';
5
5
  const CONTINUATION_TTL_SECONDS = 600;
6
6
  function fingerprint(value) {
7
7
  return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex').slice(0, 32);
@@ -30,7 +30,7 @@ const meetingReceiptList = {
30
30
  resource: 'receipt',
31
31
  method: 'receipt.list',
32
32
  action: 'meeting.receipt.list',
33
- summary: '查询会议回执列表(getMeetingReceiptMemberInfoList;isMobile 缺省 false,缺失会导致后端 NPE)',
33
+ summary: '查询会议回执列表(getMeetingReceiptMemberInfoList;isMobile 缺省 false、memberType 缺省 1,二者缺失均会导致后端 NPE→500)',
34
34
  capability: 'receipt',
35
35
  risk: 'read',
36
36
  inputSchema: {
@@ -38,8 +38,8 @@ const meetingReceiptList = {
38
38
  additionalProperties: false,
39
39
  properties: {
40
40
  mtId: { ...SCHEMA_ID, description: '会议 id(必填)' },
41
- memberType: { type: 'integer', enum: [1, 2, 3], description: '人员类型:1=人员 2=客户 3=其他(默认 1)' },
42
- isMobile: { type: 'boolean', description: '移动端标记(默认 false)' },
41
+ memberType: { type: 'integer', enum: [1, 2, 3], description: '人员类型:1=人员 2=客户 3=其他(默认 1,**不传会导致后端 500**)' },
42
+ isMobile: { type: 'boolean', description: '移动端标记(默认 false,缺失会导致后端 NPE→500)' },
43
43
  current: { type: 'integer' },
44
44
  pageSize: { type: 'integer' },
45
45
  },
@@ -49,11 +49,12 @@ const meetingReceiptList = {
49
49
  const body = {
50
50
  mtId: toIdString(args.mtId, 'mtId'),
51
51
  isMobile: args.isMobile === undefined ? false : args.isMobile === true,
52
+ // 2026-10-08 真机实测(10.12.105.186):**不传 memberType 必 500「系统内部错误」**,
53
+ // 传 1 即正常返回。历史文档只提醒了 isMobile,实际 memberType 同样不能缺省。
54
+ memberType: args.memberType === undefined ? 1 : args.memberType,
52
55
  current: typeof args.current === 'number' && args.current > 0 ? args.current : 1,
53
56
  pageSize: typeof args.pageSize === 'number' && args.pageSize > 0 ? args.pageSize : 50,
54
57
  };
55
- if (args.memberType !== undefined)
56
- body.memberType = args.memberType;
57
58
  const response = await host.request({
58
59
  method: 'POST',
59
60
  path: '/api/meeting/receiptMember/getMeetingReceiptMemberInfoList',
@@ -69,7 +70,9 @@ const meetingReceiptList = {
69
70
  // ---------------------------------------------------------------------------
70
71
  function normalizeReceiptUpdate(args) {
71
72
  const mtId = toIdString(args.mtId, 'mtId');
72
- const memberId = toIdString(args.memberId, 'memberId');
73
+ // memberId 允许两种形态:人员 id(数字)或姓名文本 —— 列表返回的 `memberId` 字段其实是**姓名**
74
+ // (人员 id 在 `memberIdId`),若只接受数字会与列表结果自相矛盾(2026-10-08 真机实测)。
75
+ const memberId = toMemberSelector(args.memberId);
73
76
  const attendStatus = args.attendStatus;
74
77
  if (attendStatus !== 0 && attendStatus !== 1 && attendStatus !== 2) {
75
78
  throw meetingValidationError('attendStatus_invalid', 'attendStatus 仅允许 0=未填写 1=参加 2=不参加(回执"否"传 2)');
@@ -81,8 +84,24 @@ function normalizeReceiptUpdate(args) {
81
84
  ...(args.remark !== undefined ? { remark: String(args.remark) } : {}),
82
85
  };
83
86
  }
84
- /** 回查回执列表,按 memberId 定位回执记录 id(建会时后端自动生成,每参会人一条)。 */
85
- async function locateReceiptId(mtId, memberId, env) {
87
+ /**
88
+ * 人员选择器:数字 → 人员 id;其它非空文本 → 姓名(用于按名字定位回执记录)。
89
+ * 列表返回里 `memberId` 是姓名、`memberIdId` 才是人员 id,故两种写法都要接受。
90
+ */
91
+ function toMemberSelector(value) {
92
+ if (typeof value === 'number')
93
+ return toIdString(value, 'memberId');
94
+ if (typeof value === 'string') {
95
+ const trimmed = value.trim();
96
+ if (/^\d+$/u.test(trimmed))
97
+ return trimmed;
98
+ if (trimmed)
99
+ return trimmed;
100
+ }
101
+ throw meetingValidationError('memberId_invalid', 'memberId 必须是人员 id(数字)或参会人姓名');
102
+ }
103
+ /** 回查回执列表,按人员 id / 姓名定位回执记录 id(建会时后端自动生成,每参会人一条)。 */
104
+ async function locateReceipt(mtId, memberId, env) {
86
105
  const response = await env.host.request({
87
106
  method: 'POST',
88
107
  path: '/api/meeting/receiptMember/getMeetingReceiptMemberInfoList',
@@ -90,11 +109,20 @@ async function locateReceiptId(mtId, memberId, env) {
90
109
  body: { mtId, memberType: 1, isMobile: false, current: 1, pageSize: 100 },
91
110
  });
92
111
  const { rows } = extractRows(response);
93
- const hit = rows.find((row) => String(row?.memberId) === memberId);
112
+ // ⚠️ 列表行的字段语义(2026-10-08 真机实测):`memberId` = 姓名、`memberIdId` = 人员 id。
113
+ // 老实现只比对 `memberId`,传入真实人员 id 永远匹配不到 ⇒ 回执更新不可用。
114
+ const hit = rows.find((row) => {
115
+ const byId = String(row?.memberIdId ?? '') === memberId;
116
+ const byName = String(row?.memberId ?? '').trim() === memberId;
117
+ return byId || byName;
118
+ });
94
119
  if (!hit || hit.id === undefined) {
95
- throw meetingValidationError('receipt_not_found', `未找到该参会人(memberId=${memberId})的回执记录,请先通过 meeting.receipt.list 核对`);
120
+ throw meetingValidationError('receipt_not_found', `未找到该参会人(memberId=${memberId})的回执记录,请先通过 meeting.receipt.list 核对(列表中 memberId 是姓名、memberIdId 是人员 id)`);
96
121
  }
97
- return String(hit.id);
122
+ return {
123
+ receiptId: String(hit.id),
124
+ memberIdResolved: String(hit.memberIdId ?? hit.memberId ?? memberId),
125
+ };
98
126
  }
99
127
  const meetingReceiptUpdatePrepare = {
100
128
  name: 'meeting.receipt.update.prepare',
@@ -110,7 +138,7 @@ const meetingReceiptUpdatePrepare = {
110
138
  additionalProperties: false,
111
139
  properties: {
112
140
  mtId: { ...SCHEMA_ID, description: '会议 id' },
113
- memberId: { ...SCHEMA_ID, description: '参会人员 id(用于定位回执记录)' },
141
+ memberId: { type: 'string', description: '参会人员 id 或姓名(用于定位回执记录;列表里 memberId 是姓名、memberIdId 才是人员 id,两者都接受)' },
114
142
  attendStatus: { type: 'integer', enum: [0, 1, 2], description: '0=未填写 1=参加 2=不参加' },
115
143
  remark: { type: 'string', description: '备注(如"请假")' },
116
144
  },
@@ -118,9 +146,10 @@ const meetingReceiptUpdatePrepare = {
118
146
  },
119
147
  handler: async (args, env) => {
120
148
  const normalized = normalizeReceiptUpdate(args);
121
- const receiptId = await locateReceiptId(String(normalized.mtId), String(normalized.memberId), env);
149
+ const located = await locateReceipt(String(normalized.mtId), String(normalized.memberId), env);
122
150
  const session = await env.host.getSession();
123
- const payload = { id: receiptId, ...normalized };
151
+ // 写请求 body 统一带**解析后的人员 id**(列表里的 memberId 是姓名,直接回传姓名对服务端无意义)。
152
+ const payload = { id: located.receiptId, ...normalized, memberId: located.memberIdResolved };
124
153
  const token = await env.continuation.issue({
125
154
  operation: 'meeting.receipt.update.apply',
126
155
  baseUrl: session.baseUrl,
@@ -153,18 +182,22 @@ const meetingReceiptUpdateApply = {
153
182
  confirm: { const: true },
154
183
  continuation: { type: 'string', minLength: 1 },
155
184
  mtId: SCHEMA_ID,
156
- memberId: SCHEMA_ID,
185
+ memberId: { type: 'string' },
157
186
  attendStatus: { type: 'integer', enum: [0, 1, 2] },
158
187
  remark: { type: 'string' },
159
188
  },
160
- required: ['confirm', 'continuation', 'mtId', 'memberId', 'attendStatus'],
189
+ required: ['confirm', 'continuation'],
161
190
  },
162
191
  handler: async (args, env) => {
163
192
  requireConfirm(args, 'meeting.receipt.update.apply');
164
193
  const lease = await verifyLease(args, 'meeting.receipt.update.apply', env);
165
- const normalized = normalizeReceiptUpdate(args);
166
- if (lease.fingerprint !== fingerprint(normalized)) {
167
- throw meetingValidationError('target_changed', '回执内容与 prepare 时的快照不一致,请重新执行 prepare');
194
+ // 只传 confirm+continuation 时直接用 prepare 快照;传了业务字段则与快照合并后校验一致性。
195
+ const business = pickBusinessInput(args);
196
+ if (Object.keys(business).length > 0) {
197
+ const normalized = normalizeReceiptUpdate(mergeSnapshot(lease.payload, args));
198
+ if (lease.fingerprint !== fingerprint(normalized)) {
199
+ throw meetingValidationError('target_changed', '回执内容与 prepare 时的快照不一致,请重新执行 prepare');
200
+ }
168
201
  }
169
202
  const payload = lease.payload;
170
203
  let response;
@@ -178,7 +211,7 @@ const meetingReceiptUpdateApply = {
178
211
  }
179
212
  catch (error) {
180
213
  if (isNetworkError(error)) {
181
- throw meetingPartialError('回执更新请求发送后连接中断,是否已生效不确定,禁止自动重试,请通过 meeting.receipt.list 复核', { attempted: { mtId: normalized.mtId, memberId: normalized.memberId } }, { stage: 'receipt-update' });
214
+ throw meetingPartialError('回执更新请求发送后连接中断,是否已生效不确定,禁止自动重试,请通过 meeting.receipt.list 复核', { attempted: { mtId: payload.mtId, memberId: payload.memberId } }, { stage: 'receipt-update' });
182
215
  }
183
216
  throw error;
184
217
  }
@@ -61,6 +61,34 @@ export function toIdString(value, label) {
61
61
  return value;
62
62
  throw meetingValidationError('id_invalid', `${label} 必须是数字 id(超长 id 用字符串)`);
63
63
  }
64
+ /** 取 apply 入参里的业务字段(排除 confirm/continuation)。 */
65
+ export function pickBusinessInput(args) {
66
+ const out = {};
67
+ for (const [key, value] of Object.entries(args)) {
68
+ if (key === 'confirm' || key === 'continuation')
69
+ continue;
70
+ if (value === undefined)
71
+ continue;
72
+ out[key] = value;
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * 标准版 apply 的入参合并:**以 prepare 快照为基**,入参里的业务字段作为覆盖。
78
+ *
79
+ * 为什么需要:prepare 已经把「用户将要确认的内容」规范化后存进 continuation,而各 apply 的
80
+ * 入参 schema 又要求回传 name/beginDatetime/endDatetime 等业务字段 —— 文档与调用方习惯都是
81
+ * 「apply 只传 confirm + continuation」,于是出现「文档说只传两个参数,实际报 name 必填」的矛盾
82
+ * (2026-09-23、2026-10-08 两次真机踩到)。
83
+ *
84
+ * 语义:只传 confirm+continuation → 直接用快照(即预览过的内容);
85
+ * 传入了不同值 → 合并后由 fingerprint 校验报 `target_changed`(提示重新 prepare),
86
+ * 因此不会绕过「确认过的内容」这道闸门。
87
+ */
88
+ export function mergeSnapshot(snapshot, args) {
89
+ const base = snapshot && typeof snapshot === 'object' ? { ...snapshot } : {};
90
+ return { ...base, ...pickBusinessInput(args) };
91
+ }
64
92
  /** 逗号分隔 id 串:接受数组或字符串,输出字符串。 */
65
93
  export function toIdCsv(value, label) {
66
94
  if (value === undefined || value === null || value === '')
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { meetingConfirmationError, meetingPartialError, meetingValidationError } from '../errors.js';
3
3
  import { meetingSuccess, SCHEMA_ID } from './types.js';
4
- import { extractRows, toIdString } from './shared.js';
4
+ import { extractRows, mergeSnapshot, pickBusinessInput, toIdString } from './shared.js';
5
5
  const CONTINUATION_TTL_SECONDS = 600;
6
6
  function fingerprint(value) {
7
7
  return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex').slice(0, 32);
@@ -212,13 +212,23 @@ function signWritePair(spec) {
212
212
  continuation: { type: 'string', minLength: 1 },
213
213
  ...inputProps,
214
214
  },
215
- required: ['confirm', 'continuation', 'id'],
215
+ required: ['confirm', 'continuation'],
216
216
  },
217
217
  handler: async (args, env) => {
218
218
  requireConfirm(args, applyName);
219
219
  const lease = await verifyLease(args, applyName, env);
220
- const target = await resolveSignTarget(args, env, applyName);
221
- const payload = { id: target.signRecordId, meetingid: target.meetingId };
220
+ // 只传 confirm+continuation 时用 prepare 快照(快照里已含解析好的签到记录 id 与会议 id);
221
+ // 传了 id/mtId 才重新回查定位并校验一致性。
222
+ const business = pickBusinessInput(args);
223
+ let payload;
224
+ if (Object.keys(business).length === 0) {
225
+ const snapshot = lease.payload;
226
+ payload = { id: String(snapshot.id ?? ''), meetingid: String(snapshot.meetingid ?? '') };
227
+ }
228
+ else {
229
+ const target = await resolveSignTarget(mergeSnapshot(lease.payload, args), env, applyName);
230
+ payload = { id: target.signRecordId, meetingid: target.meetingId };
231
+ }
222
232
  if (lease.fingerprint !== fingerprint(payload)) {
223
233
  throw meetingValidationError('target_changed', '签到记录或会议与 prepare 时的快照不一致,请重新执行 prepare');
224
234
  }
@@ -1,4 +1,5 @@
1
1
  import { assertDatajsonSuccess, assertWeaverSuccess, } from '../host.js';
2
+ import { projectValidationError } from '../errors.js';
2
3
  import { SCHEMA_FILTERS, SCHEMA_FIELDS, SCHEMA_ID_STRING, SCHEMA_PAGE_NO, SCHEMA_PAGE_SIZE, projectSuccess, schemaOf, } from './types.js';
3
4
  import { compactFilters, idString, operatorId, positiveInt, queryText, } from './shared.js';
4
5
  const APP_TAG = 'weaver-project-eb';
@@ -36,12 +37,12 @@ function outputFields(value) {
36
37
  if (value === undefined)
37
38
  return null;
38
39
  if (!Array.isArray(value) || value.length === 0) {
39
- throw new Error('fields 必须是非空字符串数组');
40
+ throw projectValidationError('field_invalid', 'fields 必须是非空字符串数组');
40
41
  }
41
42
  const fields = [];
42
43
  for (const item of value) {
43
44
  if (typeof item !== 'string' || !/^[A-Za-z]/.test(item) || /[^A-Za-z0-9_]/.test(item)) {
44
- throw new Error('fields 包含非法字段名');
45
+ throw projectValidationError('field_invalid', 'fields 包含非法字段名');
45
46
  }
46
47
  fields.push(item);
47
48
  }
@@ -65,12 +66,12 @@ function normalizeFieldNames(value, inputName) {
65
66
  if (value === undefined)
66
67
  return [];
67
68
  if (!Array.isArray(value) || value.length === 0) {
68
- throw new Error(`${inputName} 必须是非空字符串数组`);
69
+ throw projectValidationError('field_invalid', `${inputName} 必须是非空字符串数组`);
69
70
  }
70
71
  const names = [];
71
72
  for (const item of value) {
72
73
  if (typeof item !== 'string' || item.trim() === '' || item.length > 100) {
73
- throw new Error(`${inputName} 包含非法字段名`);
74
+ throw projectValidationError('field_invalid', `${inputName} 包含非法字段名`);
74
75
  }
75
76
  names.push(item);
76
77
  }
@@ -130,7 +131,7 @@ async function decorateDetailUrls(host, operation, datajson) {
130
131
  }
131
132
  function table(value) {
132
133
  if (value !== 'task' && value !== 'project') {
133
- throw new Error('table must be task or project');
134
+ throw projectValidationError('field_invalid', 'table 必须是 task 或 project');
134
135
  }
135
136
  return value;
136
137
  }
@@ -209,7 +210,7 @@ function listOperation(name, path, nameField, descField, statusField) {
209
210
  await resolveCurrentUserFilters(filters, host);
210
211
  const allStatuses = args.allStatuses === true;
211
212
  if (allStatuses && (filters.sys_status !== undefined || filters[statusField] !== undefined)) {
212
- throw new Error("allStatuses 不能与 sys_status 或业务状态同时传");
213
+ throw projectValidationError('field_conflict', 'allStatuses 不能与 sys_status 或业务状态同时传');
213
214
  }
214
215
  if (allStatuses) {
215
216
  delete filters.sys_status;
@@ -189,15 +189,20 @@ export function continuationReplayError(replay, expectedOperation) {
189
189
  return workflowAlreadyAppliedError(operation, replay);
190
190
  }
191
191
  if (replay.state === 'saving') {
192
- // 写入请求已发出但结果未知:一律按"结果不确定"处理(先只读核对,禁止重复提交)。
193
- // 恢复路径按命令形态分述:单命令(batchSubmit)没有 Prepare 可重跑,只能等幂等窗口过去后原样重跑。
194
- const recovery = /Apply$/u.test(operation)
195
- ? '确认未生效后再重跑对应的 Prepare 获取新句柄。'
196
- : '确认未生效后,等本次写入的幂等保护窗口过去(约 1 分钟)再原样重跑同一条命令(同一批 requestId 在窗口内会被拦住)。';
197
- return workflowPartialError('该 continuation 对应的上一次执行**已经发出写入请求,但结果未确认**(进程被中止或回执丢失),'
198
- + '本次未执行、也不要重复提交。请先只读核对(如 workflow.search --category done --requestId <目标ID>'
199
- + ',或 workflow.search --category mine 看是否已出现目标条目)确认真实状态,'
200
- + recovery, replay, { stage: 'continuation_replay', ...replay });
192
+ // 写入请求已发出但结果未知:一律按"结果不确定"处理,**本次不执行、也不重放**。
193
+ //
194
+ // 恢复路径按**句柄形态**分述,且**不再出现"等一会儿原样重跑"**——`createForm` 签发的
195
+ // `wc-` 句柄是没有幂等窗口的(`consume` 不传 `ttlMs`,一次性且永久),"等约 1 分钟再跑"
196
+ // 对它是结构性无效的死路(2026-09-28 用户裁定改写)。批量写侧虽有 60s 确定性键窗口,
197
+ // 但同样不该由模型自动重跑。
198
+ const recovery = operation === 'workflow.createForm'
199
+ ? '要重新发起,请**重新执行 workflow.createForm 获取新句柄**(本次句柄已消费,重发同一句柄无效)。'
200
+ : /Apply$/u.test(operation)
201
+ ? '确认未生效后,重跑对应的 Prepare 获取新句柄。'
202
+ : '该命令没有可重跑的 Prepare,也**不要原样重跑**;请把结果如实告知用户。';
203
+ return workflowPartialError('该 continuation 对应的上一次执行**已经发出写入请求,但结果未确认**(进程被中止或回执丢失),本次未执行。'
204
+ + '**请把"上一次结果未确认"如实告知用户并停下,不要自行继续**'
205
+ + '(不要重试、不要改用其它命令反复推断、不要替用户下结论)。' + recovery, replay, { stage: 'continuation_replay', ...replay });
201
206
  }
202
207
  if (replay.state === 'claimed') {
203
208
  // 只抢到句柄、未发出任何写入:并发的另一次执行正在占用,等它结束再重试即可。
@@ -124,10 +124,20 @@ export const FORM_ENDPOINTS = {
124
124
  create: '/api/workflow/core/flow/create',
125
125
  /** 提交(复用查询侧同一路径) */
126
126
  submit: '/api/workflow/core/paService/v1/submitRequest',
127
- /** 节点符合性校验 */
127
+ /**
128
+ * 节点字段校验的**第一跳**:轻量预检。
129
+ *
130
+ * 服务端只回一个布尔(是否需要把校验结果弹给用户),据此决定要不要再去取详细规则集
131
+ * (`conformanceSet` + `verificationConditionRule`)。开关 `passPopUpPrompt`/`noPassPrompt`
132
+ * 都为真时前端**不调**本接口,直接取详细规则集(2026-09-28 双 HAR 实证)。
133
+ */
134
+ verifyNodeFieldByList: '/api/workflow/core/verification/verifyNodeFieldByList',
135
+ /** 节点符合性校验(详细规则集,仅在需要展示校验结果时才取) */
128
136
  conformanceSet: '/api/bs/workflow/pathdef/nodeConformance/getConformanceSetByVerification',
129
137
  verificationRequestData: '/api/workflow/core/verification/getRequestDataByRequestId',
130
138
  verificationConditionRule: '/api/bs/workflow/pathdef/nodeConformance/verificationConditionRule',
139
+ /** 提交已创建的流程(创建后页面重载拿到 view 页鉴权再提交,与真机同路径) */
140
+ flowSubmit: '/api/workflow/core/flow/submit',
131
141
  /** 租户级文件上传设置(单文件大小上限 + 格式黑白名单) */
132
142
  tenantSetting: '/api/file/tenant/setting',
133
143
  };