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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user