d58dc2a251
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>
165 lines
5.2 KiB
JavaScript
165 lines
5.2 KiB
JavaScript
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,
|
|
};
|
|
}
|