weaver-work-cli 0.1.21 → 0.1.23

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 (31) hide show
  1. package/dist/shortcuts/invoice/host.js +5 -4
  2. package/dist/shortcuts/meeting/operations/change.js +7 -4
  3. package/dist/shortcuts/meeting/operations/create.js +5 -3
  4. package/dist/shortcuts/meeting/operations/eb/association.js +151 -0
  5. package/dist/shortcuts/meeting/operations/eb/attendance-summary.js +153 -0
  6. package/dist/shortcuts/meeting/operations/eb/catalog.js +185 -13
  7. package/dist/shortcuts/meeting/operations/eb/conflict.js +17 -11
  8. package/dist/shortcuts/meeting/operations/eb/decision.js +405 -0
  9. package/dist/shortcuts/meeting/operations/eb/factory.js +222 -35
  10. package/dist/shortcuts/meeting/operations/eb/index.js +18 -1
  11. package/dist/shortcuts/meeting/operations/eb/link.js +212 -0
  12. package/dist/shortcuts/meeting/operations/eb/task-progress.js +123 -0
  13. package/dist/shortcuts/meeting/operations/env.js +78 -61
  14. package/dist/shortcuts/meeting/operations/lifecycle.js +8 -6
  15. package/dist/shortcuts/meeting/operations/link.js +125 -0
  16. package/dist/shortcuts/meeting/operations/receipts.js +54 -21
  17. package/dist/shortcuts/meeting/operations/registry.js +2 -0
  18. package/dist/shortcuts/meeting/operations/shared.js +41 -0
  19. package/dist/shortcuts/meeting/operations/signs.js +14 -4
  20. package/dist/shortcuts/project/operations/read.js +7 -6
  21. package/docs/e10-auth.md +4 -0
  22. package/docs/invoice.md +3 -0
  23. package/package.json +1 -1
  24. package/skills/weaver-e10-meeting/SKILL.md +28 -11
  25. package/skills/weaver-e10-meeting/references/meeting-eb.md +172 -7
  26. package/skills/weaver-e10-meeting/references/meeting-receipts-signs.md +7 -2
  27. package/skills/weaver-e10-meeting/references/meeting-verification.md +271 -0
  28. package/skills/weaver-e10-meeting.zip +0 -0
  29. package/skills/weaver-e10-shijingran/SKILL.md +68 -3
  30. package/skills/weaver-e10-shijingran/references/disabled-capabilities.md +22 -0
  31. package/skills/weaver-e10-shijingran/references/source-manifest.json +12 -4
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 决议任务的**进度汇总**(供 `meeting.eb.decisionTask.list` 声明式挂载,见 catalog 的 `summarizeRows`)。
3
+ *
4
+ * 为什么需要(2026-09-21/22 真机确立的口径,三个坑每次手算都容易踩):
5
+ * 1. **任务主表的 `decision_status` 恒为「未完成」** —— 既不随明细变化,也无法经接口写入,
6
+ * 所以"任务完成了没"**只能**从 `detail1`(执行人跟进记录)读;
7
+ * 2. **`executors` 可能是单对象也可能是数组**(1 人 = 对象,多人 = 数组),直接取 length 会得到 undefined;
8
+ * 3. **`detail1` 只有"已跟进过"的人才有记录** —— 没跟进的执行人不在里面,
9
+ * 若按 detail1 统计人数会漏人(表现为"1/1 完成",其实还有个人没跟进)。
10
+ *
11
+ * 故这里统一:**以 `executors` 为人员基准,用 `detail1` 覆盖其状态,缺失的人补 `pending`**。
12
+ * 状态归一化成英文枚举便于程序判断;中文展示交给调用方翻译。
13
+ */
14
+ function toText(value) {
15
+ return value === undefined || value === null ? '' : String(value);
16
+ }
17
+ /**
18
+ * `finish_status` 归一化。
19
+ *
20
+ * EB 在不同接口/配置下可能回**中文渲染值**(已完成 / 已延期 / 未完成)或**数字 id**(0/1/2),
21
+ * 两种都兼容;无法识别的一律按 `pending`(宁可少报完成,不夸大)。
22
+ */
23
+ function normalizeFinishStatus(raw) {
24
+ const text = toText(raw).trim();
25
+ if (text === '')
26
+ return 'pending';
27
+ if (text === '1' || text.includes('已完成') || text === '完成')
28
+ return 'done';
29
+ if (text === '2' || text.includes('延期'))
30
+ return 'overdue';
31
+ return 'pending';
32
+ }
33
+ /** 人员字段(`executors` / `executor`)兼容"单对象 | 数组 | 纯 id 串",统一成 `[{id,name}]`。 */
34
+ function toPeople(raw) {
35
+ const list = raw === undefined || raw === null ? [] : (Array.isArray(raw) ? raw : [raw]);
36
+ const out = [];
37
+ const seen = new Set();
38
+ for (const item of list) {
39
+ if (item === undefined || item === null)
40
+ continue;
41
+ if (typeof item === 'object') {
42
+ const id = toText(item.id).trim();
43
+ if (id === '' || seen.has(id))
44
+ continue;
45
+ seen.add(id);
46
+ out.push({ id, name: toText(item.name) });
47
+ }
48
+ else {
49
+ const id = toText(item).trim();
50
+ if (id === '' || seen.has(id))
51
+ continue;
52
+ seen.add(id);
53
+ out.push({ id, name: '' });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /** 单行 → 任务进度。 */
59
+ function summarizeRow(row) {
60
+ const main = (row?.mainTable ?? row ?? {});
61
+ const mt = (main.mt_id ?? {});
62
+ // detail1:只有已跟进的人才有记录 ⇒ 先建索引,再按 executors 对齐(缺的补 pending)。
63
+ const followed = new Map();
64
+ const details = Array.isArray(row?.detail1) ? row.detail1 : [];
65
+ for (const item of details) {
66
+ const who = toPeople(item?.executor)[0];
67
+ if (!who)
68
+ continue;
69
+ followed.set(who.id, {
70
+ status: normalizeFinishStatus(item?.finish_status),
71
+ finishDate: toText(item?.finish_date),
72
+ });
73
+ }
74
+ const members = toPeople(main.executors).map((person) => {
75
+ const hit = followed.get(person.id);
76
+ return {
77
+ executorId: person.id,
78
+ executorName: person.name,
79
+ status: hit ? hit.status : 'pending',
80
+ finishDate: hit ? hit.finishDate : '',
81
+ };
82
+ });
83
+ // 兜底:主表没带 executors 时退化为按跟进记录统计,避免显示 0/0 让人误判"没有执行人"。
84
+ if (members.length === 0) {
85
+ for (const [id, hit] of followed) {
86
+ members.push({ executorId: id, executorName: '', status: hit.status, finishDate: hit.finishDate });
87
+ }
88
+ }
89
+ const done = members.filter((item) => item.status === 'done').length;
90
+ const overdue = members.filter((item) => item.status === 'overdue').length;
91
+ const pending = members.length - done - overdue;
92
+ const status = members.length === 0
93
+ ? 'pending'
94
+ : done === members.length
95
+ ? 'done'
96
+ : overdue > 0
97
+ ? 'overdue'
98
+ : done > 0
99
+ ? 'inProgress'
100
+ : 'pending';
101
+ return {
102
+ id: toText(main.id),
103
+ title: toText(main.decision_title),
104
+ mtId: toText(mt.id),
105
+ mtName: toText(mt.name),
106
+ plan: { begin: toText(main.begin_datetime), end: toText(main.end_datetime) },
107
+ progress: { total: members.length, done, overdue, pending, status, members },
108
+ };
109
+ }
110
+ /** `decisionTask.list` 的结果后处理:原始 `rows` 原样保留,这里只额外给出 `progress`。 */
111
+ export function summarizeTaskProgress(rows) {
112
+ const items = rows.map((row) => summarizeRow(row ?? {}));
113
+ return {
114
+ summary: {
115
+ total: items.length,
116
+ done: items.filter((item) => item.progress.status === 'done').length,
117
+ overdue: items.filter((item) => item.progress.status === 'overdue').length,
118
+ inProgress: items.filter((item) => item.progress.status === 'inProgress').length,
119
+ pending: items.filter((item) => item.progress.status === 'pending').length,
120
+ },
121
+ items,
122
+ };
123
+ }
@@ -16,6 +16,13 @@ function stateRoot(ctx) {
16
16
  return resolve(configured, '..');
17
17
  return process.env.WEAVER_WORK_CLI_HOME || process.env.WORK_CLI_HOME || resolve(homedir(), '.weaver-work-cli');
18
18
  }
19
+ /**
20
+ * 会议模块状态目录(`<stateRoot>/meeting`):环境模式缓存、EB objid 缓存等落在这里。
21
+ * 导出供 link.ts 复用,避免各文件各自推导 stateRoot 导致缓存分裂。
22
+ */
23
+ export function meetingStateDir(ctx) {
24
+ return resolve(stateRoot(ctx), 'meeting');
25
+ }
19
26
  function cacheFile(ctx) {
20
27
  return resolve(stateRoot(ctx), 'meeting', 'env-mode.json');
21
28
  }
@@ -50,6 +57,74 @@ async function writeCache(ctx, baseUrl, mode, smokePath) {
50
57
  return `环境模式缓存写入失败(不影响本次结果,下次会重新冒烟):${error instanceof Error ? error.message : String(error)}`;
51
58
  }
52
59
  }
60
+ /**
61
+ * 解析当前环境的会议模式(EB / 标准版),供 `meeting.env.detect` 与 `meeting.link`
62
+ * 共用同一套缓存与冒烟逻辑(避免两处判定不一致导致链接模式选错)。
63
+ */
64
+ export async function resolveMeetingEnvMode(host, ctx, options = {}) {
65
+ const session = await host.getSession();
66
+ // ① 默认先读缓存(按 baseUrl 固化);refresh=true 才强制重新冒烟。
67
+ if (options.refresh !== true) {
68
+ const cache = await readCache(ctx);
69
+ const cached = cache.environments[session.baseUrl];
70
+ if (cached && (cached.mode === 'eb' || cached.mode === 'standard')) {
71
+ return {
72
+ mode: cached.mode,
73
+ smokePath: cached.smokePath,
74
+ fromCache: true,
75
+ detectedAt: cached.detectedAt,
76
+ };
77
+ }
78
+ }
79
+ // ② 第 1 步:EB 版冒烟(EB 优先)。接口 404/业务失败均视为「EB 未配置」。
80
+ let ebError;
81
+ try {
82
+ await host.request({
83
+ method: 'POST',
84
+ path: EB_SMOKE_PATH,
85
+ operation: 'meeting.env.detect',
86
+ // EB list 的 datajson 内必须出现 mainTable 节点(空对象即可):缺失时服务端报
87
+ // “mainTable解析异常,请参照文档修正数据格式”,冒烟会**假失败**并把 EB 环境误判为
88
+ // 标准版(2026-09-20 真机复现)。
89
+ body: { datajson: { mainTable: {}, header: { operator: session.userId } } },
90
+ // EB 响应不含 code/status(形如 {datajson:{datas:[...]}}),默认的 isBusinessSuccess
91
+ // 会判为 false → 冒烟假失败 → EB 环境被误判为标准版。
92
+ acceptBusiness: ebBusinessSuccess,
93
+ });
94
+ const warning = await writeCache(ctx, session.baseUrl, 'eb', EB_SMOKE_PATH);
95
+ return { mode: 'eb', smokePath: EB_SMOKE_PATH, fromCache: false, warning };
96
+ }
97
+ catch (error) {
98
+ ebError = error;
99
+ }
100
+ // ③ 第 2 步:标准版冒烟(仅当 EB 冒烟未通过)。
101
+ try {
102
+ await host.request({
103
+ method: 'GET',
104
+ path: STD_CALENDAR_PATH,
105
+ operation: 'meeting.env.detect',
106
+ });
107
+ const ebDetail = ebError instanceof Error
108
+ ? `${ebError.type ?? 'api'}/${ebError.subtype ?? 'error'}: ${ebError.message}`
109
+ : String(ebError);
110
+ const warning = await writeCache(ctx, session.baseUrl, 'standard', STD_CALENDAR_PATH);
111
+ return {
112
+ mode: 'standard',
113
+ smokePath: STD_CALENDAR_PATH,
114
+ fromCache: false,
115
+ ebSmokeFailed: true,
116
+ ebSmokeError: ebDetail,
117
+ warning,
118
+ };
119
+ }
120
+ catch (stdError) {
121
+ // 两步都不通 → 不写缓存,提示接口未配置完整。
122
+ throw meetingValidationError('env_unavailable', '当前环境 EB 版与标准版会议接口冒烟均未通过,请确认会议应用已安装且接口已配置完整', {
123
+ eb: ebError instanceof Error ? ebError.message : String(ebError),
124
+ standard: stdError instanceof Error ? stdError.message : String(stdError),
125
+ });
126
+ }
127
+ }
53
128
  const meetingEnvDetect = {
54
129
  name: 'meeting.env.detect',
55
130
  resource: 'env',
@@ -67,67 +142,9 @@ const meetingEnvDetect = {
67
142
  },
68
143
  },
69
144
  handler: async (args, { host, ctx }) => {
70
- const session = await host.getSession();
71
- // ① 默认先读缓存(按 baseUrl 固化);refresh=true 才强制重新冒烟。
72
- if (args.refresh !== true) {
73
- const cache = await readCache(ctx);
74
- const cached = cache.environments[session.baseUrl];
75
- if (cached && (cached.mode === 'eb' || cached.mode === 'standard')) {
76
- return meetingSuccess('meeting.env.detect', {
77
- mode: cached.mode,
78
- smokePath: cached.smokePath,
79
- fromCache: true,
80
- detectedAt: cached.detectedAt,
81
- });
82
- }
83
- }
84
- // ② 第 1 步:EB 版冒烟(EB 优先)。接口 404/业务失败均视为「EB 未配置」。
85
- let ebError;
86
- try {
87
- await host.request({
88
- method: 'POST',
89
- path: EB_SMOKE_PATH,
90
- operation: 'meeting.env.detect',
91
- // EB list 的 datajson 内必须出现 mainTable 节点(空对象即可):缺失时服务端报
92
- // “mainTable解析异常,请参照文档修正数据格式”,冒烟会**假失败**并把 EB 环境误判为
93
- // 标准版(2026-09-20 真机复现)。
94
- body: { datajson: { mainTable: {}, header: { operator: session.userId } } },
95
- // EB 响应不含 code/status(形如 {datajson:{datas:[...]}}),默认的 isBusinessSuccess
96
- // 会判为 false → 冒烟假失败 → EB 环境被误判为标准版。
97
- acceptBusiness: ebBusinessSuccess,
98
- });
99
- const warning = await writeCache(ctx, session.baseUrl, 'eb', EB_SMOKE_PATH);
100
- return meetingSuccess('meeting.env.detect', { mode: 'eb', smokePath: EB_SMOKE_PATH, fromCache: false }, warning ? { warnings: [warning] } : undefined);
101
- }
102
- catch (error) {
103
- ebError = error;
104
- }
105
- // ③ 第 2 步:标准版冒烟(仅当 EB 冒烟未通过)。
106
- try {
107
- await host.request({
108
- method: 'GET',
109
- path: STD_CALENDAR_PATH,
110
- operation: 'meeting.env.detect',
111
- });
112
- const ebDetail = ebError instanceof Error
113
- ? `${ebError.type ?? 'api'}/${ebError.subtype ?? 'error'}: ${ebError.message}`
114
- : String(ebError);
115
- const warning = await writeCache(ctx, session.baseUrl, 'standard', STD_CALENDAR_PATH);
116
- return meetingSuccess('meeting.env.detect', {
117
- mode: 'standard',
118
- smokePath: STD_CALENDAR_PATH,
119
- fromCache: false,
120
- ebSmokeFailed: true,
121
- ebSmokeError: ebDetail,
122
- }, warning ? { warnings: [warning] } : undefined);
123
- }
124
- catch (stdError) {
125
- // 两步都不通 → 不写缓存,提示接口未配置完整。
126
- throw meetingValidationError('env_unavailable', '当前环境 EB 版与标准版会议接口冒烟均未通过,请确认会议应用已安装且接口已配置完整', {
127
- eb: ebError instanceof Error ? ebError.message : String(ebError),
128
- standard: stdError instanceof Error ? stdError.message : String(stdError),
129
- });
130
- }
145
+ const result = await resolveMeetingEnvMode(host, ctx, { refresh: args.refresh === true });
146
+ const { warning, ...data } = result;
147
+ return meetingSuccess('meeting.env.detect', data, warning ? { warnings: [warning] } : undefined);
131
148
  },
132
149
  };
133
150
  // ---------------------------------------------------------------------------
@@ -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
  ];
@@ -0,0 +1,125 @@
1
+ import { meetingValidationError } from '../errors.js';
2
+ import { meetingSuccess, schemaOf, SCHEMA_ID, SCHEMA_ID_LIST } from './types.js';
3
+ import { resolveMeetingEnvMode } from './env.js';
4
+ import { MEETING_EB_TAGS, STANDARD_NO_PAGE_TYPES, buildEbCardPath, buildStandardMeetingPath, meetingAbsoluteUrl, meetingMarkdownLink, resolveMeetingEbObjs, } from './eb/link.js';
5
+ const EB_TYPES = Object.keys(MEETING_EB_TAGS);
6
+ /**
7
+ * `meeting.link` —— 会议对象「打开链接」生成器(EB 版 + 标准版)。
8
+ *
9
+ * 为什么要单独一个 operation:链接地址随模式不同、且 EB 版需要按表 tag 动态解析 objid
10
+ * (objid 随环境变化、禁止硬编码,也不等于 formId),不能让 Agent 手工拼 URL。
11
+ * 解析失败只降级为「不附链接」并写 warnings,**不影响**主业务结果。
12
+ */
13
+ const meetingLink = {
14
+ name: 'meeting.link',
15
+ resource: 'link',
16
+ method: 'resolve',
17
+ action: 'meeting.link',
18
+ summary: '生成对象详情页链接:EB 版 /sp/ebdfpage/card/0/{objid}/{dataid}(按 tag 解析 objid 并缓存);标准版会议 /sp/meeting/meetingSingle?meetingId=(会议室/会议类型无单页,不附链接)',
19
+ capability: 'link',
20
+ risk: 'read',
21
+ inputSchema: schemaOf({
22
+ dataId: { ...SCHEMA_ID, description: '对象 id:标准版=会议 id;EB 版=表单数据 id(单条查询用)' },
23
+ dataIds: { ...SCHEMA_ID_LIST, description: '批量:一次为多条数据生成链接(列表输出用)' },
24
+ type: {
25
+ type: 'string',
26
+ enum: [...EB_TYPES],
27
+ description: '对象类别(默认 meeting)。映射 EB 表 tag,详见 references/meeting-eb.md 的链接章节',
28
+ },
29
+ tag: { type: 'string', description: '直接指定 EB 表 tag(如 uf_mt_detail),与 type 二选一,优先于 type' },
30
+ mode: {
31
+ type: 'string',
32
+ enum: ['auto', 'eb', 'standard'],
33
+ description: 'auto(默认)=按当前环境模式自动判定;也可显式指定',
34
+ },
35
+ name: { type: 'string', description: '链接显示名;传入后同时返回 markdown 内联链接' },
36
+ refresh: { type: 'boolean', description: 'true=强制重新解析 EB objid(忽略缓存)' },
37
+ }, { required: [] }),
38
+ handler: async (args, { host, ctx }) => {
39
+ const single = args.dataId !== undefined && args.dataId !== null && String(args.dataId).trim() !== '';
40
+ const batch = Array.isArray(args.dataIds)
41
+ ? args.dataIds.map((item) => String(item)).filter((item) => item.trim() !== '')
42
+ : [];
43
+ const dataIds = single ? [String(args.dataId)] : batch;
44
+ if (dataIds.length === 0) {
45
+ throw meetingValidationError('link_data_id_required', '生成链接必须提供 dataId(单条)或 dataIds(批量)');
46
+ }
47
+ const requestedType = args.type === undefined ? 'meeting' : String(args.type);
48
+ const tag = typeof args.tag === 'string' && args.tag.trim() !== ''
49
+ ? args.tag.trim()
50
+ : MEETING_EB_TAGS[requestedType];
51
+ if (!tag) {
52
+ throw meetingValidationError('link_type_invalid', `未知对象类别:${requestedType}(可用:${EB_TYPES.join(' / ')},或用 tag 直接指定表 tag)`);
53
+ }
54
+ const session = await host.getSession();
55
+ const warnings = [];
56
+ const requestedMode = args.mode === undefined ? 'auto' : String(args.mode);
57
+ let mode;
58
+ if (requestedMode === 'eb' || requestedMode === 'standard') {
59
+ mode = requestedMode;
60
+ }
61
+ else {
62
+ const detected = await resolveMeetingEnvMode(host, ctx, {});
63
+ mode = detected.mode;
64
+ if (detected.warning)
65
+ warnings.push(detected.warning);
66
+ }
67
+ let objId = null;
68
+ let appId = null;
69
+ let fromCache = false;
70
+ if (mode === 'standard') {
71
+ // 标准版:只有会议详情有单页;会议室 / 会议类型明确无单页(standalone 口径)。
72
+ if (STANDARD_NO_PAGE_TYPES.includes(requestedType)) {
73
+ warnings.push(`标准版「${requestedType}」没有单页地址,按口径只输出数据、不附链接`);
74
+ }
75
+ else if (requestedType !== 'meeting') {
76
+ warnings.push(`标准版未记录「${requestedType}」的单页地址,已跳过链接生成`);
77
+ }
78
+ }
79
+ else {
80
+ const resolved = await resolveMeetingEbObjs(host, ctx, { tags: [tag], refresh: args.refresh === true });
81
+ objId = resolved.objs[tag]?.objId ?? null;
82
+ appId = resolved.appId;
83
+ fromCache = resolved.fromCache;
84
+ warnings.push(...resolved.warnings);
85
+ if (!objId)
86
+ warnings.push(`未解析到 tag=${tag} 的 objid,本次不附链接`);
87
+ }
88
+ const noStdPage = mode === 'standard' && requestedType !== 'meeting';
89
+ const items = dataIds.map((dataId) => {
90
+ const path = mode === 'eb'
91
+ ? (objId ? buildEbCardPath(objId, dataId) : null)
92
+ : (noStdPage ? null : buildStandardMeetingPath(dataId));
93
+ const url = meetingAbsoluteUrl(session.baseUrl, path);
94
+ const item = { dataId, path, url };
95
+ const markdown = typeof args.name === 'string' && args.name.trim() !== ''
96
+ ? meetingMarkdownLink(args.name, url)
97
+ : undefined;
98
+ if (markdown)
99
+ item.markdown = markdown;
100
+ return item;
101
+ });
102
+ // source:objid 的来源(cache/dynamic);标准版不需要 objid ⇒ none。
103
+ const source = mode === 'eb' ? (objId ? (fromCache ? 'cache' : 'dynamic') : 'none') : 'none';
104
+ const data = {
105
+ mode,
106
+ type: requestedType,
107
+ // 标准版不涉及 EB 表 tag,返回 null 避免误用。
108
+ tag: mode === 'eb' ? tag : null,
109
+ baseUrl: session.baseUrl,
110
+ appId,
111
+ objId,
112
+ source,
113
+ items,
114
+ };
115
+ if (single) {
116
+ const [first] = items;
117
+ data.path = first.path;
118
+ data.url = first.url;
119
+ if (first.markdown)
120
+ data.markdown = first.markdown;
121
+ }
122
+ return meetingSuccess('meeting.link', data, warnings.length ? { warnings } : {});
123
+ },
124
+ };
125
+ export const meetingLinkOperations = [meetingLink];
@@ -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
  }
@@ -9,6 +9,7 @@ import { meetingCreateOperations } from './create.js';
9
9
  import { meetingLifecycleOperations } from './lifecycle.js';
10
10
  import { meetingReceiptOperations } from './receipts.js';
11
11
  import { meetingSignOperations } from './signs.js';
12
+ import { meetingLinkOperations } from './link.js';
12
13
  import { meetingEbOperations } from './eb/index.js';
13
14
  function buildRegistry() {
14
15
  const registry = new Map();
@@ -23,6 +24,7 @@ function buildRegistry() {
23
24
  ...meetingChangeOperations,
24
25
  ...meetingReceiptOperations,
25
26
  ...meetingSignOperations,
27
+ ...meetingLinkOperations,
26
28
  ...meetingEbOperations,
27
29
  ];
28
30
  for (const definition of definitions) {