feat(插件): 改用受控表单提交产品任务

This commit is contained in:
cc
2026-08-11 12:34:40 +08:00
parent 82c915d1d7
commit 72e5396e6d
5 changed files with 58 additions and 43 deletions
@@ -1,34 +1,40 @@
#!/usr/bin/env node
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 { dirname, isAbsolute, resolve } from 'node:path';
import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs';
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');
try {
const value = normalize(await readStdin());
const identity = tokens(value);
const confirmation = argument('--confirm');
const dryRun = process.argv.includes('--dry-run');
if (!confirmation || confirmation !== identity.confirmation_token)
throw new Error('确认令牌缺失或已过期,请重新预览并确认');
const receipts = await readJson(receiptPath, {});
if (receipts[identity.submission_key]) {
console.log(JSON.stringify({ ok: true, duplicate: true, ...receipts[identity.submission_key] }, null, 2));
if (!dryRun && receipts[identity.submission_key]) {
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);
}
const auth = lark(['auth', 'status', '--json', '--verify']);
const user = auth.identities?.user;
if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权');
const fieldList = lark(['base', '+field-list', '--base-token', baseToken, '--table-id', tableId, '--as', 'user']);
const fields = new Map((fieldList.fields ?? []).map((field) => [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);
}