Files
memind/wechat-admin.mjs
T
john 229805a070 Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:06:43 +08:00

354 lines
11 KiB
JavaScript

import { loadWechatMpConfig } from './wechat-mp.mjs';
import { nextDailyRunAt, normalizeTimezone } from './schedule-time.mjs';
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
function clampLimit(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT;
return Math.min(Math.floor(parsed), MAX_LIMIT);
}
function normalizeStatus(value, allowed) {
const status = typeof value === 'string' ? value.trim() : '';
return allowed.includes(status) ? status : null;
}
function likePattern(value) {
const text = typeof value === 'string' ? value.trim() : '';
return text ? `%${text}%` : null;
}
export function maskWechatId(value) {
const text = String(value ?? '');
if (text.length <= 8) return text ? '***' : '';
return `${text.slice(0, 6)}...${text.slice(-4)}`;
}
function maskAppId(value) {
const text = String(value ?? '');
if (text.length <= 10) return text || null;
return `${text.slice(0, 6)}...${text.slice(-4)}`;
}
function boolFromEnv(value) {
return value === true || value === '1' || value === 'true';
}
function mapBindingRow(row) {
return {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
status: row.status,
appId: maskAppId(row.app_id),
openidMasked: maskWechatId(row.openid),
nickname: row.nickname,
avatarUrl: row.avatar_url,
lastLoginAt: Number(row.last_login_at ?? 0),
boundAt: Number(row.bound_at ?? 0),
routeId: row.route_id,
routeStatus: row.route_status,
agentSessionId: row.agent_session_id,
routeUpdatedAt: row.route_updated_at ? Number(row.route_updated_at) : null,
};
}
function mapMessageRow(row) {
return {
appId: maskAppId(row.app_id),
openidMasked: maskWechatId(row.openid),
msgId: row.msg_id,
status: row.status,
agentSessionId: row.agent_session_id,
createdAt: Number(row.created_at ?? 0),
updatedAt: Number(row.updated_at ?? 0),
userId: row.user_id,
username: row.username,
displayName: row.display_name,
};
}
function mapDigestRow(row) {
return {
id: row.id,
userId: row.user_id,
username: row.username,
displayName: row.display_name,
digestType: row.digest_type,
hour: Number(row.hour),
minute: Number(row.minute),
timezone: row.timezone,
channel: row.channel,
status: row.status,
nextRunAt: Number(row.next_run_at ?? 0),
lastRunAt: row.last_run_at ? Number(row.last_run_at) : null,
attempts: Number(row.attempts ?? 0),
lastError: row.last_error,
sourceText: row.source_text,
createdAt: Number(row.created_at ?? 0),
updatedAt: Number(row.updated_at ?? 0),
};
}
function mapDeliveryRow(row) {
return {
id: row.id,
reminderId: row.reminder_id,
subscriptionId: row.subscription_id,
userId: row.user_id,
username: row.username,
displayName: row.display_name,
channel: row.channel,
status: row.status,
providerMessageId: row.provider_message_id,
errorCode: row.error_code,
errorMessage: row.error_message,
createdAt: Number(row.created_at ?? 0),
digestType: row.digest_type,
};
}
export function createWechatAdminService(
pool,
{
config = loadWechatMpConfig(),
scheduleEnabled = boolFromEnv(process.env.H5_SCHEDULE_ENABLED),
reminderWorkerEnabled = boolFromEnv(process.env.H5_REMINDER_WORKER_ENABLED),
} = {},
) {
async function getSummary() {
const now = Date.now();
const recentSince = now - 24 * 60 * 60 * 1000;
const [[identityRows], [routeRows], [messageRows], [digestRows], [deliveryRows]] =
await Promise.all([
pool.query('SELECT COUNT(*) AS count FROM h5_user_wechat_identities'),
pool.query(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active
FROM h5_wechat_agent_routes`,
),
pool.query(
`SELECT status, COUNT(*) AS count
FROM h5_wechat_mp_messages
WHERE updated_at >= ?
GROUP BY status`,
[recentSince],
),
pool.query(
`SELECT status, COUNT(*) AS count
FROM h5_schedule_digest_subscriptions
GROUP BY status`,
),
pool.query(
`SELECT status, COUNT(*) AS count
FROM h5_schedule_delivery_logs
WHERE created_at >= ?
GROUP BY status`,
[recentSince],
),
]);
return {
config: {
mpEnabled: Boolean(config?.enabled),
scheduleEnabled,
reminderWorkerEnabled,
appId: maskAppId(config?.appId),
publicBaseUrl: config?.publicBaseUrl ?? null,
bindPath: config?.bindPath ?? null,
tokenEndpointConfigured: Boolean(config?.tokenUrl),
customerServiceEndpointConfigured: Boolean(config?.customerServiceUrl),
},
counts: {
boundUsers: Number(identityRows[0]?.count ?? 0),
routes: {
total: Number(routeRows[0]?.total ?? 0),
active: Number(routeRows[0]?.active ?? 0),
},
recentMessages: Object.fromEntries(
messageRows.map((row) => [row.status, Number(row.count ?? 0)]),
),
digests: Object.fromEntries(digestRows.map((row) => [row.status, Number(row.count ?? 0)])),
recentDeliveries: Object.fromEntries(
deliveryRows.map((row) => [row.status, Number(row.count ?? 0)]),
),
},
};
}
async function listBindings({ search, limit } = {}) {
const values = [];
const where = [];
const pattern = likePattern(search);
if (pattern) {
where.push(
`(u.username LIKE ? OR u.display_name LIKE ? OR wi.nickname LIKE ? OR wi.openid LIKE ?)`,
);
values.push(pattern, pattern, pattern, pattern);
}
values.push(clampLimit(limit));
const [rows] = await pool.query(
`SELECT
wi.user_id, wi.app_id, wi.openid, wi.nickname, wi.avatar_url,
wi.last_login_at, wi.created_at AS bound_at,
u.username, u.display_name, u.status,
r.id AS route_id, r.status AS route_status, r.agent_session_id,
r.updated_at AS route_updated_at
FROM h5_user_wechat_identities wi
JOIN h5_users u ON u.id = wi.user_id
LEFT JOIN h5_wechat_agent_routes r ON r.app_id = wi.app_id AND r.openid = wi.openid
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY wi.updated_at DESC
LIMIT ?`,
values,
);
return { bindings: rows.map(mapBindingRow) };
}
async function listMessages({ status, limit } = {}) {
const values = [];
const where = [];
const normalizedStatus = normalizeStatus(status, ['processing', 'done', 'failed']);
if (normalizedStatus) {
where.push('m.status = ?');
values.push(normalizedStatus);
}
values.push(clampLimit(limit));
const [rows] = await pool.query(
`SELECT
m.app_id, m.openid, m.msg_id, m.status, m.agent_session_id,
m.created_at, m.updated_at,
wi.user_id, u.username, u.display_name
FROM h5_wechat_mp_messages m
LEFT JOIN h5_user_wechat_identities wi ON wi.app_id = m.app_id AND wi.openid = m.openid
LEFT JOIN h5_users u ON u.id = wi.user_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY m.updated_at DESC
LIMIT ?`,
values,
);
return { messages: rows.map(mapMessageRow) };
}
async function listDigests({ 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_schedule_digest_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 { digests: rows.map(mapDigestRow) };
}
async function listDeliveries({ status, limit } = {}) {
const values = [];
const where = [];
const normalizedStatus = normalizeStatus(status, ['success', 'failed']);
if (normalizedStatus) {
where.push('d.status = ?');
values.push(normalizedStatus);
}
values.push(clampLimit(limit));
const [rows] = await pool.query(
`SELECT
d.*, u.username, u.display_name, s.digest_type
FROM h5_schedule_delivery_logs d
JOIN h5_users u ON u.id = d.user_id
LEFT JOIN h5_schedule_digest_subscriptions s ON s.id = d.subscription_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY d.created_at DESC
LIMIT ?`,
values,
);
return { deliveries: rows.map(mapDeliveryRow) };
}
async function clearRouteForUser(userId) {
const [rows] = await pool.query(
`SELECT app_id, openid
FROM h5_user_wechat_identities
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT 1`,
[userId],
);
const identity = rows[0];
if (!identity) return { ok: false, message: '用户未绑定服务号' };
const [result] = await pool.query(
`DELETE FROM h5_wechat_agent_routes
WHERE app_id = ? AND openid = ?`,
[identity.app_id, identity.openid],
);
return {
ok: true,
deleted: Number(result.affectedRows ?? 0),
openidMasked: maskWechatId(identity.openid),
};
}
async function cancelDigest(id) {
const now = Date.now();
const [result] = await pool.query(
`UPDATE h5_schedule_digest_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 resumeDigest(id) {
const [rows] = await pool.query(
`SELECT id, hour, minute, timezone
FROM h5_schedule_digest_subscriptions
WHERE id = ?
LIMIT 1`,
[id],
);
const digest = rows[0];
if (!digest) return { ok: false, message: '订阅不存在' };
const timezone = normalizeTimezone(digest.timezone);
const now = Date.now();
const nextRun = nextDailyRunAt({
hour: Number(digest.hour),
minute: Number(digest.minute),
timezone,
now,
});
await pool.query(
`UPDATE h5_schedule_digest_subscriptions
SET status = 'active', next_run_at = ?, attempts = 0, locked_until = NULL,
last_error = NULL, timezone = ?, updated_at = ?
WHERE id = ?`,
[nextRun, timezone, now, id],
);
return { ok: true, nextRunAt: nextRun };
}
return {
getSummary,
listBindings,
listMessages,
listDigests,
listDeliveries,
clearRouteForUser,
cancelDigest,
resumeDigest,
};
}