diff --git a/plugins/easyai-product-intake/.codex-plugin/plugin.json b/plugins/easyai-product-intake/.codex-plugin/plugin.json index cbdbef1..1a041fa 100644 --- a/plugins/easyai-product-intake/.codex-plugin/plugin.json +++ b/plugins/easyai-product-intake/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "easyai-product-intake", - "version": "0.1.0+codex.20260811042632", + "version": "0.1.0+codex.20260811043301", "description": "\u5e2e\u52a9 EasyAI \u4ea7\u54c1\u7ecf\u7406\u4ee5\u7edf\u4e00\u683c\u5f0f\u63d0\u4ea4\u9700\u6c42\u548c Bug \u5230\u98de\u4e66\u591a\u7ef4\u8868\u683c\u3002", "author": { "name": "EasyAI" diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md b/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md index e3af1a8..ae62221 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md @@ -12,7 +12,7 @@ description: 将 EasyAI 产品需求或 Bug 规范登记到指定飞书多维表 3. 按 [base-schema.md](references/base-schema.md) 构造 JSON,通过 stdin 传给 `scripts/prepare-intake.mjs`。 4. 展示脚本返回的中文预览。必须等待用户明确说“确认提交”或等价表达。 5. 确认后,将同一 JSON 通过 stdin 传给 `scripts/submit-intake.mjs --confirm `。 -6. 返回 `record_id`、提交幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成。 +6. 返回提交回执、幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成并通过飞书通知。 ## 命令 @@ -21,11 +21,11 @@ printf '%s' '' | node scripts/prepare-intake.mjs printf '%s' '' | node scripts/submit-intake.mjs --confirm '' ``` -`submit-intake.mjs` 使用当前电脑已授权的飞书用户身份,不要要求用户提供 token、Cookie 或密码。 +`submit-intake.mjs` 使用当前电脑已授权的飞书用户身份提交企业内受控表单,不要要求用户提供 token、Cookie 或密码。 ## 安全边界 -- 只能写入 [base-schema.md](references/base-schema.md) 列出的产品字段。 +- 只能通过受控表单写入 [base-schema.md](references/base-schema.md) 列出的产品字段。 - 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。 - 不经用户确认不得运行提交脚本。 - 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。 @@ -35,5 +35,5 @@ printf '%s' '' | node scripts/submit-intake.mjs --confirm ' [field.name, field])); + const form = lark(['base', '+form-detail', '--share-token', formShareToken, '--as', 'user']); + if (form.base_token !== baseToken) throw new Error('飞书表单指向了非预期 Base'); + const fields = new Map((form.questions ?? []).map((field) => [field.title, field])); for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源']) - if (!fields.has(name)) throw new Error(`Base 缺少必需字段: ${name}`); + if (!fields.has(name)) throw new Error(`受控表单缺少必需字段: ${name}`); const payload = { '类型': value.type, @@ -47,33 +53,41 @@ try { ...(value.desired_at ? { '期望完成时间': value.desired_at } : {}), ...(value.references ? { '参考资料': value.references } : {}), }; - const created = lark([ - 'base', '+record-upsert', '--base-token', baseToken, '--table-id', tableId, - '--json', JSON.stringify(payload), '--as', 'user', + for (const attachment of value.attachments) await assertSafePath(attachment); + const submission = { + fields: payload, + ...(value.attachments.length ? { attachments: { '附件': value.attachments } } : {}), + }; + if (!dryRun) { + receipts[identity.submission_key] = { + submission_url: formUrl, + submission_key: identity.submission_key, + status_code: 'submitting', + status: '正在提交;若进程中断,请等待中央调度器核对,勿重复提交', + }; + await writeReceipts(receipts); + } + const submitted = lark([ + 'base', '+form-submit', '--share-token', formShareToken, + ...(value.attachments.length ? ['--base-token', baseToken] : []), + '--json', JSON.stringify(submission), '--as', 'user', + ...(dryRun ? ['--dry-run'] : []), ]); - const recordId = findId(created, 'rec'); - if (!recordId) throw new Error('飞书未返回 record_id'); - const recordUrl = findUrl(created) ?? `https://feishu.cn/base/${baseToken}?table=${tableId}&record=${recordId}`; + if (dryRun) { + console.log(JSON.stringify({ ok: true, dry_run: true, submission_key: identity.submission_key, submitted }, null, 2)); + process.exit(0); + } + const receipt = { - record_id: recordId, - record_url: recordUrl, + submission_url: formUrl, submission_key: identity.submission_key, + status_code: 'submitted', status: '已登记,等待系统分配任务编号', }; receipts[identity.submission_key] = receipt; - await mkdir(dirname(receiptPath), { recursive: true, mode: 0o700 }); - await writeFile(receiptPath, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 }); + await writeReceipts(receipts); - const attachmentField = fields.get('附件'); - for (const attachment of value.attachments) { - if (!attachmentField?.id) throw new Error('Base 缺少附件字段'); - await assertSafePath(attachment); - lark([ - 'base', '+record-upload-attachment', '--base-token', baseToken, '--table-id', tableId, - '--record-id', recordId, '--field-id', attachmentField.id, '--file', attachment, '--as', 'user', - ]); - } console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2)); } catch (error) { console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' })); @@ -101,13 +115,10 @@ async function assertSafePath(path) { const target = await realpath(resolve(root, path)); if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`); } -function findId(value, prefix) { - if (typeof value === 'string' && value.startsWith(prefix)) return value; - if (Array.isArray(value)) for (const item of value) { const found = findId(item, prefix); if (found) return found; } - if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findId(item, prefix); if (found) return found; } -} -function findUrl(value) { - if (typeof value === 'string' && /^https?:\/\//.test(value)) return value; - if (Array.isArray(value)) for (const item of value) { const found = findUrl(item); if (found) return found; } - if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findUrl(item); if (found) return found; } +async function writeReceipts(receipts) { + const directory = dirname(receiptPath); + const temporary = `${receiptPath}.${process.pid}.tmp`; + await mkdir(directory, { recursive: true, mode: 0o700 }); + await writeFile(temporary, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 }); + await rename(temporary, receiptPath); }