feat(wechat): add Intent Transaction Layer with unified task schema

Introduce Draft → Confirm → Commit flow for WeChat schedule intents behind
feature flags, plus h5_tasks dual-write/read aggregation and rollout scripts
so reminders and automations get explicit user confirmation before persisting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-24 15:59:17 +08:00
parent b93c92a3e2
commit d58dc2a251
45 changed files with 4972 additions and 30 deletions
+164
View File
@@ -0,0 +1,164 @@
import crypto from 'node:crypto';
import { intentDraftTtlMs } from './intent-transaction-config.mjs';
function nowMs(clock) {
return clock.now();
}
function rowToDraft(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
layer: row.layer,
draftType: row.draft_type,
actionLevel: Number(row.action_level ?? 1),
title: row.title,
payload: parseJson(row.payload_json),
cardText: row.card_text,
status: row.status,
sourceChannel: row.source_channel,
sourceMessageId: row.source_message_id ?? null,
sourceText: row.source_text ?? null,
committedRef: parseJson(row.committed_ref_json),
eventLog: parseJson(row.event_log_json) ?? [],
expiresAt: row.expires_at == null ? null : Number(row.expires_at),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
function parseJson(value) {
if (value == null || value === '') return null;
if (typeof value === 'object') return value;
try { return JSON.parse(value); } catch { return null; }
}
export function createIntentDraftService(pool, { clock = { now: () => Date.now() }, ttlMs = intentDraftTtlMs() } = {}) {
if (!pool) throw new Error('缺少数据库连接');
const appendEvent = (events, type, detail = {}) => [
...(Array.isArray(events) ? events : []),
{ type, at: clock.now(), ...detail },
];
const expireStaleDrafts = async (userId) => {
const now = nowMs(clock);
await pool.query(
`UPDATE h5_intent_drafts
SET status = 'expired', updated_at = ?
WHERE user_id = ? AND status = 'draft' AND expires_at IS NOT NULL AND expires_at <= ?`,
[now, userId, now],
);
};
const getPendingDraft = async (userId) => {
if (!userId) return null;
await expireStaleDrafts(userId);
const [rows] = await pool.query(
`SELECT * FROM h5_intent_drafts
WHERE user_id = ? AND status = 'draft'
ORDER BY created_at DESC
LIMIT 1`,
[userId],
);
return rowToDraft(rows?.[0]);
};
const createDraft = async ({
userId,
layer,
draftType,
actionLevel = 1,
title,
payload,
cardText,
sourceChannel = 'wechat',
sourceMessageId = null,
sourceText = null,
}) => {
if (!userId) throw new Error('缺少用户');
const safeTitle = String(title ?? '').trim() || '待确认任务';
const now = nowMs(clock);
await expireStaleDrafts(userId);
await pool.query(
`UPDATE h5_intent_drafts
SET status = 'cancelled', updated_at = ?
WHERE user_id = ? AND status = 'draft'`,
[now, userId],
);
const id = crypto.randomUUID();
const expiresAt = now + ttlMs;
const eventLog = appendEvent([], 'draft_created', { layer, draftType });
await pool.query(
`INSERT INTO h5_intent_drafts
(id, user_id, layer, draft_type, action_level, title, payload_json, card_text, status,
source_channel, source_message_id, source_text, committed_ref_json, event_log_json,
expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, NULL, ?, ?, ?, ?)`,
[
id,
userId,
layer,
draftType,
actionLevel,
safeTitle,
JSON.stringify(payload ?? {}),
cardText,
sourceChannel,
sourceMessageId,
sourceText,
JSON.stringify(eventLog),
expiresAt,
now,
now,
],
);
const [rows] = await pool.query(`SELECT * FROM h5_intent_drafts WHERE id = ? LIMIT 1`, [id]);
return rowToDraft(rows?.[0]);
};
const cancelDraft = async (draftId, userId, { reason = 'user_cancel' } = {}) => {
const now = nowMs(clock);
const [rows] = await pool.query(
`SELECT * FROM h5_intent_drafts WHERE id = ? AND user_id = ? LIMIT 1`,
[draftId, userId],
);
const row = rows?.[0];
if (!row) throw new Error('草稿不存在');
if (row.status !== 'draft') return rowToDraft(row);
const eventLog = appendEvent(parseJson(row.event_log_json), 'draft_cancelled', { reason });
await pool.query(
`UPDATE h5_intent_drafts SET status = 'cancelled', event_log_json = ?, updated_at = ? WHERE id = ?`,
[JSON.stringify(eventLog), now, draftId],
);
const [updated] = await pool.query(`SELECT * FROM h5_intent_drafts WHERE id = ? LIMIT 1`, [draftId]);
return rowToDraft(updated?.[0]);
};
const markDraftCommitted = async (draftId, userId, committedRef = {}) => {
const now = nowMs(clock);
const [rows] = await pool.query(
`SELECT * FROM h5_intent_drafts WHERE id = ? AND user_id = ? LIMIT 1`,
[draftId, userId],
);
const row = rows?.[0];
if (!row) throw new Error('草稿不存在');
const eventLog = appendEvent(parseJson(row.event_log_json), 'draft_committed', { committedRef });
await pool.query(
`UPDATE h5_intent_drafts
SET status = 'committed', committed_ref_json = ?, event_log_json = ?, updated_at = ?
WHERE id = ?`,
[JSON.stringify(committedRef), JSON.stringify(eventLog), now, draftId],
);
const [updated] = await pool.query(`SELECT * FROM h5_intent_drafts WHERE id = ? LIMIT 1`, [draftId]);
return rowToDraft(updated?.[0]);
};
return {
getPendingDraft,
createDraft,
cancelDraft,
markDraftCommitted,
};
}