feat(插件): 改用受控表单提交产品任务
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "easyai-product-intake",
|
"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",
|
"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": {
|
"author": {
|
||||||
"name": "EasyAI"
|
"name": "EasyAI"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ description: 将 EasyAI 产品需求或 Bug 规范登记到指定飞书多维表
|
|||||||
3. 按 [base-schema.md](references/base-schema.md) 构造 JSON,通过 stdin 传给 `scripts/prepare-intake.mjs`。
|
3. 按 [base-schema.md](references/base-schema.md) 构造 JSON,通过 stdin 传给 `scripts/prepare-intake.mjs`。
|
||||||
4. 展示脚本返回的中文预览。必须等待用户明确说“确认提交”或等价表达。
|
4. 展示脚本返回的中文预览。必须等待用户明确说“确认提交”或等价表达。
|
||||||
5. 确认后,将同一 JSON 通过 stdin 传给 `scripts/submit-intake.mjs --confirm <confirmation_token>`。
|
5. 确认后,将同一 JSON 通过 stdin 传给 `scripts/submit-intake.mjs --confirm <confirmation_token>`。
|
||||||
6. 返回 `record_id`、提交幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成。
|
6. 返回提交回执、幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成并通过飞书通知。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -21,11 +21,11 @@ printf '%s' '<json>' | node scripts/prepare-intake.mjs
|
|||||||
printf '%s' '<json>' | node scripts/submit-intake.mjs --confirm '<confirmation_token>'
|
printf '%s' '<json>' | node scripts/submit-intake.mjs --confirm '<confirmation_token>'
|
||||||
```
|
```
|
||||||
|
|
||||||
`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、部署、验收、锁、重试和审计字段。
|
- 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。
|
||||||
- 不经用户确认不得运行提交脚本。
|
- 不经用户确认不得运行提交脚本。
|
||||||
- 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。
|
- 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。
|
||||||
@@ -35,5 +35,5 @@ printf '%s' '<json>' | node scripts/submit-intake.mjs --confirm '<confirmation_t
|
|||||||
|
|
||||||
- 信息不完整:只返回下一组最多 3 个问题。
|
- 信息不完整:只返回下一组最多 3 个问题。
|
||||||
- 待确认:展示类型、标题、背景、验收标准、优先级和附件。
|
- 待确认:展示类型、标题、背景、验收标准、优先级和附件。
|
||||||
- 提交成功:明确说明“已登记,等待系统分配任务编号”。
|
- 提交成功:返回提交回执并明确说明“已登记,等待系统分配任务编号”。
|
||||||
- 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。
|
- 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
- Base Token: `Ed21b9VwNaZfiesxWgDc67wXnoc`
|
- Base Token: `Ed21b9VwNaZfiesxWgDc67wXnoc`
|
||||||
- 数据表: `tblelMMlOif1YHPD`(需求与缺陷)
|
- 数据表: `tblelMMlOif1YHPD`(需求与缺陷)
|
||||||
|
- 受控表单: `产品需求与 Bug 提交`
|
||||||
|
- 表单提交范围: 仅企业内获得链接的用户
|
||||||
|
|
||||||
允许提交的字段:
|
允许提交的字段:
|
||||||
|
|
||||||
@@ -21,6 +23,6 @@
|
|||||||
| `references` | 参考资料 | 可选文本 |
|
| `references` | 参考资料 | 可选文本 |
|
||||||
| `attachments` | 附件 | 当前目录内相对路径数组 |
|
| `attachments` | 附件 | 当前目录内相对路径数组 |
|
||||||
|
|
||||||
脚本自动写入但不接受模型指定:`产品负责人`、`提交幂等键`、`提交来源`。
|
脚本通过受控表单自动写入但不接受模型指定:`产品负责人`、`提交幂等键`、`提交来源`。
|
||||||
|
|
||||||
其他字段全部禁止写入。
|
任务编号由中央调度器生成;其他系统字段不在受控表单中,全部禁止写入。
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ function requiredEnum(value, label, options) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
function optionalEnumList(value, label, options) {
|
function optionalEnumList(value, label, options) {
|
||||||
const list = value === undefined ? [] : requiredList(value, label);
|
const list = value === undefined || (Array.isArray(value) && value.length === 0)
|
||||||
|
? []
|
||||||
|
: requiredList(value, label);
|
||||||
for (const item of list) requiredEnum(item, label, options);
|
for (const item of list) requiredEnum(item, label, options);
|
||||||
return [...new Set(list)];
|
return [...new Set(list)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
import { mkdir, realpath, rename, writeFile } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||||
import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs';
|
import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs';
|
||||||
|
|
||||||
const baseToken = 'Ed21b9VwNaZfiesxWgDc67wXnoc';
|
const baseToken = 'Ed21b9VwNaZfiesxWgDc67wXnoc';
|
||||||
const tableId = 'tblelMMlOif1YHPD';
|
const formShareToken = 'shrcnT6yRL9olcpFkppFkUcKe5e';
|
||||||
|
const formUrl = 'https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e';
|
||||||
const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json');
|
const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = normalize(await readStdin());
|
const value = normalize(await readStdin());
|
||||||
const identity = tokens(value);
|
const identity = tokens(value);
|
||||||
const confirmation = argument('--confirm');
|
const confirmation = argument('--confirm');
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
if (!confirmation || confirmation !== identity.confirmation_token)
|
if (!confirmation || confirmation !== identity.confirmation_token)
|
||||||
throw new Error('确认令牌缺失或已过期,请重新预览并确认');
|
throw new Error('确认令牌缺失或已过期,请重新预览并确认');
|
||||||
|
|
||||||
const receipts = await readJson(receiptPath, {});
|
const receipts = await readJson(receiptPath, {});
|
||||||
if (receipts[identity.submission_key]) {
|
if (!dryRun && receipts[identity.submission_key]) {
|
||||||
console.log(JSON.stringify({ ok: true, duplicate: true, ...receipts[identity.submission_key] }, null, 2));
|
const existing = receipts[identity.submission_key];
|
||||||
|
if (existing.status_code === 'submitting')
|
||||||
|
throw new Error(`提交 ${identity.submission_key} 的结果待中央调度器核对,请勿重复提交`);
|
||||||
|
console.log(JSON.stringify({ ok: true, duplicate: true, ...existing }, null, 2));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = lark(['auth', 'status', '--json', '--verify']);
|
const auth = lark(['auth', 'status', '--json', '--verify']);
|
||||||
const user = auth.identities?.user;
|
const user = auth.identities?.user;
|
||||||
if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权');
|
if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权');
|
||||||
const fieldList = lark(['base', '+field-list', '--base-token', baseToken, '--table-id', tableId, '--as', 'user']);
|
const form = lark(['base', '+form-detail', '--share-token', formShareToken, '--as', 'user']);
|
||||||
const fields = new Map((fieldList.fields ?? []).map((field) => [field.name, field]));
|
if (form.base_token !== baseToken) throw new Error('飞书表单指向了非预期 Base');
|
||||||
|
const fields = new Map((form.questions ?? []).map((field) => [field.title, field]));
|
||||||
for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源'])
|
for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源'])
|
||||||
if (!fields.has(name)) throw new Error(`Base 缺少必需字段: ${name}`);
|
if (!fields.has(name)) throw new Error(`受控表单缺少必需字段: ${name}`);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
'类型': value.type,
|
'类型': value.type,
|
||||||
@@ -47,33 +53,41 @@ try {
|
|||||||
...(value.desired_at ? { '期望完成时间': value.desired_at } : {}),
|
...(value.desired_at ? { '期望完成时间': value.desired_at } : {}),
|
||||||
...(value.references ? { '参考资料': value.references } : {}),
|
...(value.references ? { '参考资料': value.references } : {}),
|
||||||
};
|
};
|
||||||
const created = lark([
|
for (const attachment of value.attachments) await assertSafePath(attachment);
|
||||||
'base', '+record-upsert', '--base-token', baseToken, '--table-id', tableId,
|
const submission = {
|
||||||
'--json', JSON.stringify(payload), '--as', 'user',
|
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 = {
|
const receipt = {
|
||||||
record_id: recordId,
|
submission_url: formUrl,
|
||||||
record_url: recordUrl,
|
|
||||||
submission_key: identity.submission_key,
|
submission_key: identity.submission_key,
|
||||||
|
status_code: 'submitted',
|
||||||
status: '已登记,等待系统分配任务编号',
|
status: '已登记,等待系统分配任务编号',
|
||||||
};
|
};
|
||||||
receipts[identity.submission_key] = receipt;
|
receipts[identity.submission_key] = receipt;
|
||||||
await mkdir(dirname(receiptPath), { recursive: true, mode: 0o700 });
|
await writeReceipts(receipts);
|
||||||
await writeFile(receiptPath, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 });
|
|
||||||
|
|
||||||
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));
|
console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' }));
|
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));
|
const target = await realpath(resolve(root, path));
|
||||||
if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`);
|
if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`);
|
||||||
}
|
}
|
||||||
function findId(value, prefix) {
|
async function writeReceipts(receipts) {
|
||||||
if (typeof value === 'string' && value.startsWith(prefix)) return value;
|
const directory = dirname(receiptPath);
|
||||||
if (Array.isArray(value)) for (const item of value) { const found = findId(item, prefix); if (found) return found; }
|
const temporary = `${receiptPath}.${process.pid}.tmp`;
|
||||||
if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findId(item, prefix); if (found) return found; }
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
}
|
await writeFile(temporary, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 });
|
||||||
function findUrl(value) {
|
await rename(temporary, receiptPath);
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user