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>
This commit is contained in:
john
2026-06-19 23:06:43 +08:00
parent b0f5d6a51c
commit 229805a070
241 changed files with 13190 additions and 902 deletions
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
ROOT="/Users/john/Project/Memind"
BACKUP="/Users/john/Project/.codex-baselines/Memind/wechat-service-agent-20260618-084500/.env.before-prod-mp-20260618-092652"
NODE_BIN="/opt/homebrew/opt/node@24/bin/node"
cd "$ROOT"
if [[ ! -f "$BACKUP" ]]; then
echo "backup env not found: $BACKUP" >&2
exit 1
fi
cp "$BACKUP" .env
PORT_PID="$(lsof -tiTCP:8081 -sTCP:LISTEN || true)"
if [[ -n "$PORT_PID" ]]; then
kill "$PORT_PID"
sleep 2
fi
nohup "$NODE_BIN" server.mjs >> h5.log 2>&1 &
echo $! > .h5.pid
sleep 3
curl -s http://127.0.0.1:8081/api/status
+2
View File
@@ -12,6 +12,7 @@ import { SummaryPage } from './pages/admin/SummaryPage';
import { UsersPage } from './pages/admin/UsersPage';
import { LlmPage } from './pages/admin/LlmPage';
import { BillingPage } from './pages/admin/BillingPage';
import { WechatPage } from './pages/admin/WechatPage';
export function App() {
return (
@@ -44,6 +45,7 @@ export function App() {
<Route path="users" element={<UsersPage />} />
<Route path="llm" element={<LlmPage />} />
<Route path="billing" element={<BillingPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
+137
View File
@@ -74,6 +74,92 @@ export type UsageRecord = {
createdAt: string;
};
export type WechatAdminSummary = {
config: {
mpEnabled: boolean;
scheduleEnabled: boolean;
reminderWorkerEnabled: boolean;
appId: string | null;
publicBaseUrl: string | null;
bindPath: string | null;
tokenEndpointConfigured: boolean;
customerServiceEndpointConfigured: boolean;
};
counts: {
boundUsers: number;
routes: { total: number; active: number };
recentMessages: Record<string, number>;
digests: Record<string, number>;
recentDeliveries: Record<string, number>;
};
};
export type WechatBinding = {
userId: string;
username: string;
displayName: string;
status: string;
appId: string | null;
openidMasked: string;
nickname: string | null;
avatarUrl: string | null;
lastLoginAt: number;
boundAt: number;
routeId: string | null;
routeStatus: string | null;
agentSessionId: string | null;
routeUpdatedAt: number | null;
};
export type WechatMessage = {
appId: string | null;
openidMasked: string;
msgId: string;
status: string;
agentSessionId: string | null;
createdAt: number;
updatedAt: number;
userId: string | null;
username: string | null;
displayName: string | null;
};
export type WechatDigestSubscription = {
id: string;
userId: string;
username: string;
displayName: string;
digestType: string;
hour: number;
minute: number;
timezone: string;
channel: string;
status: string;
nextRunAt: number;
lastRunAt: number | null;
attempts: number;
lastError: string | null;
sourceText: string | null;
createdAt: number;
updatedAt: number;
};
export type WechatDeliveryLog = {
id: string;
reminderId: string | null;
subscriptionId: string | null;
userId: string;
username: string;
displayName: string;
channel: string;
status: string;
providerMessageId: string | null;
errorCode: string | null;
errorMessage: string | null;
createdAt: number;
digestType: string | null;
};
// ─── Summary ──────────────────────────────────────────────────────────────────
export async function fetchAdminSummary() {
@@ -149,6 +235,57 @@ export async function fetchAdminUsage(params: { page?: number; pageSize?: number
);
}
// ─── WeChat MP ────────────────────────────────────────────────────────────────
export async function fetchWechatSummary() {
return adminFetch<WechatAdminSummary>('/admin-api/wechat/summary');
}
export async function fetchWechatBindings(params: { search?: string; limit?: number } = {}) {
const q = new URLSearchParams();
if (params.search) q.set('search', params.search);
if (params.limit) q.set('limit', String(params.limit));
return adminFetch<{ bindings: WechatBinding[] }>(`/admin-api/wechat/bindings?${q}`);
}
export async function fetchWechatMessages(params: { status?: string; limit?: number } = {}) {
const q = new URLSearchParams();
if (params.status) q.set('status', params.status);
if (params.limit) q.set('limit', String(params.limit));
return adminFetch<{ messages: WechatMessage[] }>(`/admin-api/wechat/messages?${q}`);
}
export async function fetchWechatDigests(params: { status?: string; limit?: number } = {}) {
const q = new URLSearchParams();
if (params.status) q.set('status', params.status);
if (params.limit) q.set('limit', String(params.limit));
return adminFetch<{ digests: WechatDigestSubscription[] }>(`/admin-api/wechat/digests?${q}`);
}
export async function fetchWechatDeliveries(params: { status?: string; limit?: number } = {}) {
const q = new URLSearchParams();
if (params.status) q.set('status', params.status);
if (params.limit) q.set('limit', String(params.limit));
return adminFetch<{ deliveries: WechatDeliveryLog[] }>(`/admin-api/wechat/deliveries?${q}`);
}
export async function clearWechatRoute(userId: string) {
return adminFetch<{ ok: boolean; deleted: number; openidMasked: string }>(
`/admin-api/wechat/users/${userId}/route/clear`,
{ method: 'POST' },
);
}
export async function cancelWechatDigest(id: string) {
return adminFetch<{ ok: boolean }>(`/admin-api/wechat/digests/${id}/cancel`, { method: 'POST' });
}
export async function resumeWechatDigest(id: string) {
return adminFetch<{ ok: boolean; nextRunAt: number }>(`/admin-api/wechat/digests/${id}/resume`, {
method: 'POST',
});
}
// ─── LLM Providers ───────────────────────────────────────────────────────────
export async function fetchLlmKeys() {
+2 -1
View File
@@ -5,6 +5,7 @@ const links = [
{ to: '/admin/users', label: '用户管理' },
{ to: '/admin/llm', label: 'LLM 配置' },
{ to: '/admin/billing', label: '账单记录' },
{ to: '/admin/wechat', label: '服务号管理' },
];
export function AdminLayout() {
@@ -12,7 +13,7 @@ export function AdminLayout() {
<div className="layout">
<header>
<h1></h1>
<p style={{ color: '#68716c' }}> LLM </p>
<p style={{ color: '#68716c' }}>LLM </p>
</header>
<nav className="nav">
<NavLink
+409
View File
@@ -0,0 +1,409 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import {
cancelWechatDigest,
clearWechatRoute,
fetchWechatBindings,
fetchWechatDeliveries,
fetchWechatDigests,
fetchWechatMessages,
fetchWechatSummary,
resumeWechatDigest,
type WechatAdminSummary,
type WechatBinding,
type WechatDeliveryLog,
type WechatDigestSubscription,
type WechatMessage,
} from '../../api/admin';
function time(value?: number | null) {
if (!value) return '—';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function count(record: Record<string, number>, key: string) {
return record[key] ?? 0;
}
function badge(status?: string | null) {
if (!status) return <span style={{ color: '#68716c' }}></span>;
const color =
status === 'active' || status === 'success' || status === 'done'
? '#2f6f57'
: status === 'failed'
? '#b42318'
: '#68716c';
return <strong style={{ color, fontSize: 12 }}>{status}</strong>;
}
function userName(row: { displayName?: string | null; username?: string | null }) {
return row.displayName || row.username || '—';
}
const tableStyle = {
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
} satisfies CSSProperties;
const thStyle = {
textAlign: 'left',
color: '#68716c',
fontWeight: 600,
borderBottom: '1px solid #e7dfd1',
padding: '8px 6px',
whiteSpace: 'nowrap',
} satisfies CSSProperties;
const tdStyle = {
borderBottom: '1px solid #eee7da',
padding: '9px 6px',
verticalAlign: 'top',
} satisfies CSSProperties;
export function WechatPage() {
const [summary, setSummary] = useState<WechatAdminSummary | null>(null);
const [bindings, setBindings] = useState<WechatBinding[]>([]);
const [messages, setMessages] = useState<WechatMessage[]>([]);
const [digests, setDigests] = useState<WechatDigestSubscription[]>([]);
const [deliveries, setDeliveries] = useState<WechatDeliveryLog[]>([]);
const [search, setSearch] = useState('');
const [messageStatus, setMessageStatus] = useState('');
const [digestStatus, setDigestStatus] = useState('');
const [deliveryStatus, setDeliveryStatus] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = async () => {
setError(null);
try {
const [summaryResult, bindingResult, messageResult, digestResult, deliveryResult] =
await Promise.all([
fetchWechatSummary(),
fetchWechatBindings({ search, limit: 80 }),
fetchWechatMessages({ status: messageStatus || undefined, limit: 80 }),
fetchWechatDigests({ status: digestStatus || undefined, limit: 80 }),
fetchWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
]);
setSummary(summaryResult);
setBindings(bindingResult.bindings);
setMessages(messageResult.messages);
setDigests(digestResult.digests);
setDeliveries(deliveryResult.deliveries);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
}
};
useEffect(() => {
void load();
}, []);
const handleClearRoute = async (binding: WechatBinding) => {
if (!window.confirm(`确认清除「${userName(binding)}」的服务号会话路由?下一次用户发消息会重建路由。`)) return;
setBusy(true);
try {
await clearWechatRoute(binding.userId);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '清除失败');
} finally {
setBusy(false);
}
};
const handleCancelDigest = async (digest: WechatDigestSubscription) => {
if (!window.confirm(`确认暂停「${userName(digest)}」的每日待办推送?`)) return;
setBusy(true);
try {
await cancelWechatDigest(digest.id);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '暂停失败');
} finally {
setBusy(false);
}
};
const handleResumeDigest = async (digest: WechatDigestSubscription) => {
setBusy(true);
try {
await resumeWechatDigest(digest.id);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '恢复失败');
} finally {
setBusy(false);
}
};
if (!summary && !error) return <p>...</p>;
return (
<div className="grid">
{error ? <p className="alert">{error}</p> : null}
{summary ? (
<div
className="card"
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))', gap: 12 }}
>
<Metric label="服务号" value={summary.config.mpEnabled ? '已启用' : '未启用'} />
<Metric label="绑定用户" value={summary.counts.boundUsers} />
<Metric label="活跃路由" value={`${summary.counts.routes.active}/${summary.counts.routes.total}`} />
<Metric label="每日订阅" value={count(summary.counts.digests, 'active')} />
<Metric label="24h 消息失败" value={count(summary.counts.recentMessages, 'failed')} danger />
<Metric label="24h 投递失败" value={count(summary.counts.recentDeliveries, 'failed')} danger />
<div style={{ gridColumn: '1 / -1', color: '#68716c', fontSize: 12 }}>
AppID {summary.config.appId ?? '未配置'} · {summary.config.publicBaseUrl ?? '未配置公网地址'}
{summary.config.bindPath ? ` · 绑定路径 ${summary.config.bindPath}` : ''}
</div>
</div>
) : null}
<section className="card grid">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<h3 style={{ margin: 0 }}></h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索用户、昵称、openid"
style={{ width: 220 }}
/>
<button type="button" className="btn secondary" onClick={() => void load()} disabled={busy}>
</button>
</div>
</div>
<TableShell empty={bindings.length === 0}>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{bindings.map((binding) => (
<tr key={`${binding.userId}-${binding.openidMasked}`}>
<td style={tdStyle}>
<strong>{userName(binding)}</strong>
<div style={{ color: '#68716c', fontSize: 12 }}>{binding.username}</div>
</td>
<td style={tdStyle}>
<div>{binding.nickname || '—'}</div>
<div style={{ color: '#68716c', fontSize: 12 }}>{binding.openidMasked}</div>
</td>
<td style={tdStyle}>
{badge(binding.routeStatus)}
<div style={{ color: '#68716c', fontSize: 12 }}>{binding.agentSessionId || '无会话'}</div>
</td>
<td style={tdStyle}>{time(binding.lastLoginAt)}</td>
<td style={tdStyle}>
<button
type="button"
className="btn secondary"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => void handleClearRoute(binding)}
disabled={busy || !binding.routeId}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</TableShell>
</section>
<section className="card grid">
<SectionHead
title="每日待办推送"
value={digestStatus}
options={['', 'active', 'locked', 'failed', 'cancelled']}
onChange={setDigestStatus}
onRefresh={() => void load()}
busy={busy}
/>
<TableShell empty={digests.length === 0}>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{digests.map((digest) => (
<tr key={digest.id}>
<td style={tdStyle}>
<strong>{userName(digest)}</strong>
<div style={{ color: '#68716c', fontSize: 12 }}>{digest.username}</div>
</td>
<td style={tdStyle}>
{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}
<div style={{ color: '#68716c', fontSize: 12 }}>{digest.timezone}</div>
</td>
<td style={tdStyle}>{badge(digest.status)}</td>
<td style={tdStyle}>{time(digest.nextRunAt)}</td>
<td style={{ ...tdStyle, maxWidth: 260 }}>{digest.lastError || '—'}</td>
<td style={tdStyle}>
{digest.status === 'active' || digest.status === 'locked' ? (
<button
type="button"
className="btn secondary"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => void handleCancelDigest(digest)}
disabled={busy}
>
</button>
) : (
<button
type="button"
className="btn"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => void handleResumeDigest(digest)}
disabled={busy}
>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</TableShell>
</section>
<section className="card grid">
<SectionHead
title="最近服务号消息"
value={messageStatus}
options={['', 'processing', 'done', 'failed']}
onChange={setMessageStatus}
onRefresh={() => void load()}
busy={busy}
/>
<TableShell empty={messages.length === 0}>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}></th>
<th style={thStyle}>OpenID</th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{messages.map((message) => (
<tr key={`${message.openidMasked}-${message.msgId}`}>
<td style={tdStyle}>{userName(message)}</td>
<td style={tdStyle}>{message.openidMasked}</td>
<td style={tdStyle}>{badge(message.status)}</td>
<td style={tdStyle}>{message.agentSessionId || '—'}</td>
<td style={tdStyle}>{time(message.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</TableShell>
</section>
<section className="card grid">
<SectionHead
title="推送投递日志"
value={deliveryStatus}
options={['', 'success', 'failed']}
onChange={setDeliveryStatus}
onRefresh={() => void load()}
busy={busy}
/>
<TableShell empty={deliveries.length === 0}>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{deliveries.map((delivery) => (
<tr key={delivery.id}>
<td style={tdStyle}>{userName(delivery)}</td>
<td style={tdStyle}>{delivery.digestType ?? (delivery.reminderId ? 'reminder' : '—')}</td>
<td style={tdStyle}>{badge(delivery.status)}</td>
<td style={{ ...tdStyle, maxWidth: 320 }}>
{delivery.errorMessage || delivery.errorCode || delivery.providerMessageId || '—'}
</td>
<td style={tdStyle}>{time(delivery.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</TableShell>
</section>
</div>
);
}
function Metric({ label, value, danger = false }: { label: string; value: string | number; danger?: boolean }) {
return (
<div>
<p style={{ color: '#68716c', marginBottom: 4 }}>{label}</p>
<strong style={{ fontSize: 26, color: danger && Number(value) > 0 ? '#b42318' : undefined }}>{value}</strong>
</div>
);
}
function SectionHead({
title,
value,
options,
onChange,
onRefresh,
busy,
}: {
title: string;
value: string;
options: string[];
onChange: (value: string) => void;
onRefresh: () => void;
busy: boolean;
}) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<select value={value} onChange={(event) => onChange(event.target.value)} style={{ width: 160 }}>
{options.map((option) => (
<option key={option || 'all'} value={option}>
{option || '全部状态'}
</option>
))}
</select>
<button type="button" className="btn secondary" onClick={onRefresh} disabled={busy}>
</button>
</div>
</div>
);
}
function TableShell({ empty, children }: { empty: boolean; children: ReactNode }) {
if (empty) return <p style={{ color: '#68716c', margin: 0 }}></p>;
return <div style={{ overflowX: 'auto' }}>{children}</div>;
}
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=TKMind WeChat MP egress proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=WECHAT_EGRESS_HOST=127.0.0.1
Environment=WECHAT_EGRESS_PORT=19090
ExecStart=/usr/bin/python3 /root/wechat_egress_proxy.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target