Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -110,12 +110,51 @@ function mapDeliveryRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapBalanceAlertRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
thresholdCents: Number(row.threshold_cents),
|
||||
channel: row.channel,
|
||||
status: row.status,
|
||||
nextRunAt: Number(row.next_run_at ?? 0),
|
||||
lastRunAt: row.last_run_at ? Number(row.last_run_at) : null,
|
||||
lastNotifiedBalanceCents:
|
||||
row.last_notified_balance_cents == null ? null : Number(row.last_notified_balance_cents),
|
||||
attempts: Number(row.attempts ?? 0),
|
||||
lastError: row.last_error ?? null,
|
||||
sourceText: row.source_text ?? null,
|
||||
createdAt: Number(row.created_at ?? 0),
|
||||
updatedAt: Number(row.updated_at ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function mapWebNotificationRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
channel: row.channel,
|
||||
notificationType: row.notification_type,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
status: row.status,
|
||||
readAt: row.read_at ? Number(row.read_at) : null,
|
||||
createdAt: Number(row.created_at ?? 0),
|
||||
updatedAt: Number(row.updated_at ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function createWechatAdminService(
|
||||
pool,
|
||||
{
|
||||
config = loadWechatMpConfig(),
|
||||
scheduleEnabled = boolFromEnv(process.env.H5_SCHEDULE_ENABLED),
|
||||
reminderWorkerEnabled = boolFromEnv(process.env.H5_REMINDER_WORKER_ENABLED),
|
||||
sendWechatTextToUser = null,
|
||||
} = {},
|
||||
) {
|
||||
async function getSummary() {
|
||||
@@ -278,6 +317,173 @@ export function createWechatAdminService(
|
||||
return { deliveries: rows.map(mapDeliveryRow) };
|
||||
}
|
||||
|
||||
async function listBalanceAlerts({ status, limit } = {}) {
|
||||
const values = [];
|
||||
const where = [];
|
||||
const normalizedStatus = normalizeStatus(status, ['active', 'locked', 'failed', 'cancelled']);
|
||||
if (normalizedStatus) {
|
||||
where.push('s.status = ?');
|
||||
values.push(normalizedStatus);
|
||||
}
|
||||
values.push(clampLimit(limit));
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
s.*, u.username, u.display_name
|
||||
FROM h5_balance_alert_subscriptions s
|
||||
JOIN h5_users u ON u.id = s.user_id
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT ?`,
|
||||
values,
|
||||
);
|
||||
return { balanceAlerts: rows.map(mapBalanceAlertRow) };
|
||||
}
|
||||
|
||||
async function createBalanceAlert(input = {}) {
|
||||
const userId = typeof input.userId === 'string' ? input.userId : null;
|
||||
const thresholdCents = Number(input.thresholdCents);
|
||||
if (!userId) return { ok: false, message: '缺少用户' };
|
||||
if (!Number.isFinite(thresholdCents) || thresholdCents < 0) {
|
||||
return { ok: false, message: '余额阈值无效' };
|
||||
}
|
||||
const result = await pool.query(
|
||||
`INSERT INTO h5_balance_alert_subscriptions
|
||||
(id, user_id, threshold_cents, channel, status, next_run_at, last_run_at,
|
||||
last_notified_balance_cents, attempts, locked_until, last_error,
|
||||
source_channel, source_session_id, source_message_id, source_text, created_at, updated_at)
|
||||
VALUES (UUID(), ?, ?, 'wechat', 'active', ?, NULL, NULL, 0, NULL, NULL, 'api', NULL, NULL, ?, ?, ?)`,
|
||||
[userId, thresholdCents, Date.now(), input.sourceText ?? null, Date.now(), Date.now()],
|
||||
);
|
||||
return { ok: Number(result[0]?.affectedRows ?? 0) > 0 };
|
||||
}
|
||||
|
||||
async function cancelBalanceAlert(id) {
|
||||
const now = Date.now();
|
||||
const [result] = await pool.query(
|
||||
`UPDATE h5_balance_alert_subscriptions
|
||||
SET status = 'cancelled', locked_until = NULL, updated_at = ?
|
||||
WHERE id = ? AND status <> 'cancelled'`,
|
||||
[now, id],
|
||||
);
|
||||
return { ok: Number(result.affectedRows ?? 0) > 0 };
|
||||
}
|
||||
|
||||
async function resumeBalanceAlert(id) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, threshold_cents
|
||||
FROM h5_balance_alert_subscriptions
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
const alert = rows[0];
|
||||
if (!alert) return { ok: false, message: '订阅不存在' };
|
||||
await pool.query(
|
||||
`UPDATE h5_balance_alert_subscriptions
|
||||
SET status = 'active', next_run_at = ?, attempts = 0, locked_until = NULL,
|
||||
last_error = NULL, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[Date.now(), Date.now(), id],
|
||||
);
|
||||
return { ok: true, nextRunAt: Date.now() };
|
||||
}
|
||||
|
||||
async function listWebNotifications({ status, limit } = {}) {
|
||||
const values = ['web'];
|
||||
const where = ['n.channel = ?'];
|
||||
const normalizedStatus = normalizeStatus(status, ['unread', 'read']);
|
||||
if (normalizedStatus) {
|
||||
where.push('n.status = ?');
|
||||
values.push(normalizedStatus);
|
||||
}
|
||||
values.push(clampLimit(limit));
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
n.*, u.username, u.display_name
|
||||
FROM h5_user_notifications n
|
||||
JOIN h5_users u ON u.id = n.user_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ?`,
|
||||
values,
|
||||
);
|
||||
return { notifications: rows.map(mapWebNotificationRow) };
|
||||
}
|
||||
|
||||
async function resolveNotificationTargets(input = {}) {
|
||||
const userId = typeof input.userId === 'string' ? input.userId.trim() : '';
|
||||
const userIds = Array.isArray(input.userIds)
|
||||
? [...new Set(input.userIds.map((value) => String(value).trim()).filter(Boolean))]
|
||||
: [];
|
||||
if (userIds.length > 0) return userIds;
|
||||
if (userId) return [userId];
|
||||
const audience = typeof input.audience === 'string' ? input.audience.trim() : '';
|
||||
if (input.allUsers === true || audience === 'all') {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id
|
||||
FROM h5_users
|
||||
WHERE status <> 'disabled'
|
||||
ORDER BY created_at ASC`,
|
||||
);
|
||||
return rows.map((row) => row.id).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function createWebNotification(input = {}) {
|
||||
const title = String(input.title ?? '').trim();
|
||||
const body = String(input.body ?? '').trim();
|
||||
const notificationType = String(input.notificationType ?? 'manual').trim() || 'manual';
|
||||
const channels = Array.isArray(input.channels)
|
||||
? [...new Set(input.channels.map((value) => String(value).trim()).filter(Boolean))]
|
||||
: typeof input.channel === 'string' && input.channel.trim()
|
||||
? [input.channel.trim()]
|
||||
: ['web'];
|
||||
const includeWeb = channels.includes('web');
|
||||
const includeWechat = channels.includes('wechat');
|
||||
const targets = await resolveNotificationTargets(input);
|
||||
if (targets.length === 0) return { ok: false, message: '缺少用户' };
|
||||
if (!title) return { ok: false, message: '缺少标题' };
|
||||
if (!body) return { ok: false, message: '缺少内容' };
|
||||
if (!includeWeb && !includeWechat) return { ok: false, message: '至少选择一种通知渠道' };
|
||||
if (includeWechat && typeof sendWechatTextToUser !== 'function' && !includeWeb) {
|
||||
return { ok: false, message: '公众号推送未启用' };
|
||||
}
|
||||
const now = Date.now();
|
||||
let created = 0;
|
||||
let wechatSent = 0;
|
||||
const wechatFailures = [];
|
||||
for (const targetUserId of targets) {
|
||||
if (includeWeb) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO h5_user_notifications
|
||||
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
|
||||
VALUES (UUID(), ?, 'web', ?, ?, ?, NULL, 'unread', NULL, ?, ?)`,
|
||||
[targetUserId, notificationType, title, body, now, now],
|
||||
);
|
||||
created += Number(result?.affectedRows ?? 0);
|
||||
}
|
||||
if (includeWechat && typeof sendWechatTextToUser === 'function') {
|
||||
try {
|
||||
await sendWechatTextToUser(targetUserId, `${title}\n${body}`.trim());
|
||||
wechatSent += 1;
|
||||
} catch (err) {
|
||||
wechatFailures.push({
|
||||
userId: targetUserId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: created > 0 || wechatSent > 0,
|
||||
created,
|
||||
wechatSent,
|
||||
wechatFailures,
|
||||
targets: targets.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function clearRouteForUser(userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT app_id, openid
|
||||
@@ -346,6 +552,12 @@ export function createWechatAdminService(
|
||||
listMessages,
|
||||
listDigests,
|
||||
listDeliveries,
|
||||
listBalanceAlerts,
|
||||
createBalanceAlert,
|
||||
cancelBalanceAlert,
|
||||
resumeBalanceAlert,
|
||||
listWebNotifications,
|
||||
createWebNotification,
|
||||
clearRouteForUser,
|
||||
cancelDigest,
|
||||
resumeDigest,
|
||||
|
||||
Reference in New Issue
Block a user