From 0e26db1857b672bc92508ff7ab3a6297f3b7da97 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 12 Aug 2026 01:34:01 +0800 Subject: [PATCH] feat(analytics): add SEO/GEO page dashboard and retire Rybbit SSO Expose Umami memind-pages discoveryChannel data in adm with a public-page style table, proxy it via admin-api, and remove Rybbit configuration paths. Co-authored-by: Cursor --- .env.example | 7 +- package.json | 1 + server/app.mjs | 29 +- server/umami-analytics.mjs | 110 ++++++++ server/umami-analytics.test.mjs | 34 +++ src/App.tsx | 3 + src/admin/AdminNav.tsx | 1 + src/admin/pages/AnalyticsConfigPage.tsx | 26 +- src/admin/pages/SeoGeoAnalyticsPage.tsx | 342 ++++++++++++++++++++++++ src/api/client.ts | 51 +++- 10 files changed, 567 insertions(+), 37 deletions(-) create mode 100644 server/umami-analytics.mjs create mode 100644 server/umami-analytics.test.mjs create mode 100644 src/admin/pages/SeoGeoAnalyticsPage.tsx diff --git a/.env.example b/.env.example index fdb6f50..c4c617a 100644 --- a/.env.example +++ b/.env.example @@ -41,11 +41,10 @@ VITE_MAIN_APP_URL=https://h5.tkmind.cn # Umami 仅允许从 MemindAdm 单点登录进入(必须与 memind-analytics 一致) MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami UMAMI_SSO_USERNAME=admin +# SEO/GEO 看板拉取 Umami memind-pages API 时使用(勿提交到 Git) +# UMAMI_ADMIN_PASSWORD=change-me-umami-admin -# Rybbit 仅允许从 MemindAdm 单点登录进入(必须与 105 Rybbit 一致) -MEMIND_RYBBIT_SSO_SECRET=replace-with-the-same-random-secret-used-by-rybbit -RYBBIT_SSO_EMAIL=admin@tkmind.cn -RYBBIT_URL=https://rybbit.tkmind.cn +# Rybbit 已退役(2026-08),勿再配置 MEMIND_RYBBIT_* / RYBBIT_* # Plaza 帖子预览链接 # VITE_PLAZA_BASE=https://plaza.tkmind.cn diff --git a/package.json b/package.json index efb3247..e0cc179 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "local_restart": "bash scripts/local_restart.sh", "pro_restart": "bash scripts/pro_restart.sh", "build": "vite build", + "test:umami-analytics": "node --test server/umami-analytics.test.mjs", "test:orchestrator": "node --test server/orchestrator-routes.test.mjs", "verify:template-catalog-local": "node scripts/verify-template-catalog-local.mjs", "preview": "node scripts/preview.mjs", diff --git a/server/app.mjs b/server/app.mjs index 7f32749..20881bd 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -5,6 +5,7 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import express from 'express'; import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs'; +import { fetchMemindDiscoveryPages } from './umami-analytics.mjs'; const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -236,19 +237,23 @@ export function createAdminApp(services) { res.json({ url: `${baseUrl}/auth/memind?ticket=${encoded}.${signature}` }); }); + adminApi.get('/analytics/seo-geo-pages', requireAdmin, async (req, res) => { + try { + const config = loadMindSpaceConfig ? await loadMindSpaceConfig(pool) : null; + const result = await fetchMemindDiscoveryPages(req.query, { + env: process.env, + analyticsConfig: config?.analytics ?? {}, + }); + res.json(result); + } catch (error) { + res.status(503).json({ + message: error instanceof Error ? error.message : '无法加载 SEO/GEO 页面统计', + }); + } + }); + adminApi.get('/analytics/rybbit-sso', requireAdmin, async (_req, res) => { - const sharedSecret = process.env.MEMIND_RYBBIT_SSO_SECRET?.trim(); - if (!sharedSecret) return res.status(503).json({ message: '未配置 Rybbit 单点登录密钥' }); - const email = process.env.RYBBIT_SSO_EMAIL?.trim().toLowerCase(); - if (!email) return res.status(503).json({ message: '未配置 Rybbit 单点登录账号' }); - const baseUrl = String(process.env.RYBBIT_URL || 'https://rybbit.tkmind.cn').replace(/\/$/, ''); - const encoded = Buffer.from(JSON.stringify({ - email, - exp: Math.floor(Date.now() / 1000) + 60, - nonce: crypto.randomUUID(), - })).toString('base64url'); - const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url'); - res.json({ url: `${baseUrl}/api/auth/memind?ticket=${encoded}.${signature}` }); + return res.status(410).json({ message: 'Rybbit 已退役,请使用 Umami 与 SEO/GEO 流量看板' }); }); adminApi.get('/users', requireAdmin, async (req, res) => { diff --git a/server/umami-analytics.mjs b/server/umami-analytics.mjs new file mode 100644 index 0000000..50d0260 --- /dev/null +++ b/server/umami-analytics.mjs @@ -0,0 +1,110 @@ +const TOKEN_TTL_MS = 55 * 60 * 1000; + +let cachedToken = ''; +let cachedTokenAt = 0; +let cachedBaseUrl = ''; + +function normalizeBaseUrl(value = '') { + return String(value ?? '').trim().replace(/\/$/, ''); +} + +function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) { + const baseUrl = normalizeBaseUrl( + analyticsConfig?.analyticsUrl || env.UMAMI_URL || 'http://127.0.0.1:3100', + ); + const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim(); + const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim(); + const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim(); + return { baseUrl, username, password, websiteId }; +} + +async function loginUmami({ baseUrl, username, password }) { + if (!password) { + throw new Error('未配置 UMAMI_ADMIN_PASSWORD,无法拉取 SEO/GEO 页面统计'); + } + const response = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + signal: AbortSignal.timeout(10_000), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.message || `Umami 登录失败 (${response.status})`); + } + const token = String(body?.token ?? '').trim(); + if (!token) throw new Error('Umami 登录响应缺少 token'); + return token; +} + +async function getUmamiAuthToken(credentials) { + const now = Date.now(); + if ( + cachedToken && + cachedBaseUrl === credentials.baseUrl && + now - cachedTokenAt < TOKEN_TTL_MS + ) { + return cachedToken; + } + cachedToken = await loginUmami(credentials); + cachedTokenAt = now; + cachedBaseUrl = credentials.baseUrl; + return cachedToken; +} + +export async function fetchMemindDiscoveryPages( + query = {}, + { env = process.env, analyticsConfig = {} } = {}, +) { + const credentials = resolveUmamiCredentials(env, analyticsConfig); + if (!credentials.websiteId) { + throw new Error('未配置 Umami Website ID'); + } + const channel = String(query.discoveryChannel ?? query.channel ?? '').trim().toLowerCase(); + if (channel !== 'seo' && channel !== 'geo') { + throw new Error('discoveryChannel 必须是 seo 或 geo'); + } + + const params = new URLSearchParams(); + params.set('discoveryChannel', channel); + params.set('page', String(Math.max(Number(query.page) || 1, 1))); + params.set('pageSize', String(Math.min(Math.max(Number(query.pageSize) || 10, 1), 100))); + params.set('sortBy', String(query.sortBy || 'views')); + params.set('sortOrder', String(query.sortOrder || 'desc')); + if (query.startAt) params.set('startAt', String(query.startAt)); + if (query.endAt) params.set('endAt', String(query.endAt)); + if (query.timezone) params.set('timezone', String(query.timezone)); + if (query.generatedStartAt) params.set('generatedStartAt', String(query.generatedStartAt)); + if (query.generatedEndAt) params.set('generatedEndAt', String(query.generatedEndAt)); + + const token = await getUmamiAuthToken(credentials); + const response = await fetch( + `${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`, + { + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + signal: AbortSignal.timeout(15_000), + }, + ); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`); + } + return { + discoveryChannel: channel, + ...body, + }; +} + +export function resolvePublicPageUrl(row = {}, fallbackHost = 'm.tkmind.cn') { + const exact = String(row.pageUrl ?? '').trim(); + if (/^https?:\/\//i.test(exact)) return exact.split('#')[0]; + const hostname = String(row.hostname ?? '').trim(); + if (hostname === '127.0.0.1' || hostname === 'localhost') { + return `http://${hostname}:8081${row.urlPath}`; + } + const host = hostname || fallbackHost; + return `https://${host}${row.urlPath}`; +} diff --git a/server/umami-analytics.test.mjs b/server/umami-analytics.test.mjs new file mode 100644 index 0000000..0616609 --- /dev/null +++ b/server/umami-analytics.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { fetchMemindDiscoveryPages } from './umami-analytics.mjs'; + +test('fetchMemindDiscoveryPages rejects invalid discoveryChannel', async () => { + await assert.rejects( + () => fetchMemindDiscoveryPages( + { discoveryChannel: 'direct' }, + { env: { UMAMI_ADMIN_PASSWORD: 'secret' }, analyticsConfig: { websiteId: 'site-1' } }, + ), + /discoveryChannel 必须是 seo 或 geo/, + ); +}); + +test('fetchMemindDiscoveryPages rejects missing website id', async () => { + await assert.rejects( + () => fetchMemindDiscoveryPages( + { discoveryChannel: 'seo', startAt: '1', endAt: '2' }, + { env: { UMAMI_ADMIN_PASSWORD: 'secret' }, analyticsConfig: {} }, + ), + /未配置 Umami Website ID/, + ); +}); + +test('fetchMemindDiscoveryPages rejects missing admin password', async () => { + await assert.rejects( + () => fetchMemindDiscoveryPages( + { discoveryChannel: 'geo', startAt: '1', endAt: '2' }, + { env: {}, analyticsConfig: { websiteId: 'site-1' } }, + ), + /未配置 UMAMI_ADMIN_PASSWORD/, + ); +}); diff --git a/src/App.tsx b/src/App.tsx index 0bf78cc..0179015 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { WechatPage } from './admin/pages/WechatPage'; import { AssetGatewayPage } from './admin/pages/AssetGatewayPage'; import { BlockedWordsPage } from './admin/pages/BlockedWordsPage'; import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage'; +import { SeoGeoAnalyticsPage } from './admin/pages/SeoGeoAnalyticsPage'; import { SkillRuntimePage } from './admin/pages/SkillRuntimePage'; import { OrchestratorPage } from './admin/pages/OrchestratorPage'; import { defaultHomePath } from './lib/routes'; @@ -129,6 +130,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> } /> @@ -181,6 +183,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) { || pathname.startsWith('/system-tests') || pathname.startsWith('/policies') || pathname.startsWith('/mindspace') + || pathname.startsWith('/analytics') || pathname.startsWith('/memory-v2') || pathname.startsWith('/skill-runtime') || pathname.startsWith('/orchestrator') diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 8ce78d1..11ea138 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -33,6 +33,7 @@ const NAV_SECTIONS: NavSection[] = [ { to: '/wechat', label: '服务号' }, { to: '/mindspace', label: 'MindSpace 配置' }, { to: '/analytics', label: 'Analytics 配置' }, + { to: '/analytics/seo-geo', label: 'SEO / GEO 流量' }, { to: '/memory-v2', label: 'Memory V2' }, { to: '/mindsearch', label: 'MindSearch' }, { to: '/skill-runtime', label: 'Skill Runtime' }, diff --git a/src/admin/pages/AnalyticsConfigPage.tsx b/src/admin/pages/AnalyticsConfigPage.tsx index c81a050..b11dc42 100644 --- a/src/admin/pages/AnalyticsConfigPage.tsx +++ b/src/admin/pages/AnalyticsConfigPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react'; -import { getMindSpaceAdminConfig, getRybbitSsoUrl, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client'; +import { Link } from 'react-router-dom'; +import { getMindSpaceAdminConfig, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client'; const initial = { enabled: false, @@ -17,7 +18,6 @@ export function AnalyticsConfigPage() { const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [openingUmami, setOpeningUmami] = useState(false); - const [openingRybbit, setOpeningRybbit] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -63,28 +63,15 @@ export function AnalyticsConfigPage() { } }; - const openRybbit = async () => { - setOpeningRybbit(true); - setError(null); - try { - window.location.assign(await getRybbitSsoUrl()); - } catch (err) { - setError(err instanceof Error ? err.message : '无法打开 Rybbit'); - setOpeningRybbit(false); - } - }; - return
-

Analytics 配置

进入统计后台,或配置 Memind 生成页面的 Umami 统计。

+

Analytics 配置

配置 Umami 并进入统计后台。Rybbit 已退役,见 Memind 文档 analytics-platform.md。

{error &&

{error}

} {message &&

{message}

}
-

Rybbit 行为分析

-

查看页面访问、用户会话、停留时间、点击和会话回放等行为明细。

+

SEO / GEO 页面流量

+

按页面查看搜索引擎与生成式引擎来源访问,表格口径与 Umami「所有生成页面明细」一致。

- + 打开 SEO / GEO 流量看板
@@ -96,6 +83,7 @@ export function AnalyticsConfigPage() { +

SEO/GEO 看板还需在 memind_adm 环境变量配置 UMAMI_ADMIN_PASSWORD(与 Umami admin 账号匹配)。

diff --git a/src/admin/pages/SeoGeoAnalyticsPage.tsx b/src/admin/pages/SeoGeoAnalyticsPage.tsx new file mode 100644 index 0000000..259502e --- /dev/null +++ b/src/admin/pages/SeoGeoAnalyticsPage.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { getSeoGeoAnalyticsPages, getUmamiSsoUrl } from '../../api/client'; + +type DiscoveryChannel = 'seo' | 'geo'; + +type PageRow = { + urlPath: string; + pageUrl?: string; + hostname?: string; + pageTitle: string; + views: number; + visitors: number; + visits: number; + clicks: number; + engagedVisits: number; + forms: number; + generatedAt?: string | null; + generatedAtInferred?: boolean; + firstSeenAt?: string | null; +}; + +type PageSort = + | 'generatedAt' + | 'views' + | 'visitors' + | 'visits' + | 'clicks' + | 'engagedVisits' + | 'forms' + | 'pageTitle' + | 'firstSeenAt'; + +function formatNumber(value: number) { + return new Intl.NumberFormat('zh-CN').format(Number(value) || 0); +} + +function resolvePageUrl(row: PageRow) { + const exact = String(row.pageUrl ?? '').trim(); + if (/^https?:\/\//i.test(exact)) return exact; + const hostname = String(row.hostname ?? '').trim(); + if (hostname === '127.0.0.1' || hostname === 'localhost') { + return `http://${hostname}:8081${row.urlPath}`; + } + return hostname ? `https://${hostname}${row.urlPath}` : row.urlPath; +} + +function defaultRange() { + const end = new Date(); + const start = new Date(end); + start.setDate(start.getDate() - 29); + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }; +} + +function toRangeMs(startDate: string, endDate: string) { + const startAt = new Date(`${startDate}T00:00:00`).getTime(); + const endAt = new Date(`${endDate}T23:59:59.999`).getTime(); + return { startAt, endAt }; +} + +export function SeoGeoAnalyticsPage() { + const initialRange = useMemo(() => defaultRange(), []); + const [channel, setChannel] = useState('seo'); + const [startDate, setStartDate] = useState(initialRange.startDate); + const [endDate, setEndDate] = useState(initialRange.endDate); + const [generatedStartDate, setGeneratedStartDate] = useState(''); + const [generatedEndDate, setGeneratedEndDate] = useState(''); + const [sortBy, setSortBy] = useState('views'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(1); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [totalViews, setTotalViews] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [openingUmami, setOpeningUmami] = useState(false); + + const pageCount = Math.max(1, Math.ceil(total / 10)); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { startAt, endAt } = toRangeMs(startDate, endDate); + const result = await getSeoGeoAnalyticsPages({ + discoveryChannel: channel, + startAt, + endAt, + timezone: 'Asia/Shanghai', + page, + pageSize: 10, + sortBy, + sortOrder, + generatedStartAt: generatedStartDate + ? new Date(`${generatedStartDate}T00:00:00`).getTime() + : undefined, + generatedEndAt: generatedEndDate + ? new Date(`${generatedEndDate}T23:59:59.999`).getTime() + : undefined, + }); + setRows(Array.isArray(result.data) ? result.data : []); + setTotal(Number(result.total) || 0); + setTotalViews(Number(result.totalViews) || 0); + } catch (err) { + setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面统计失败'); + setRows([]); + setTotal(0); + setTotalViews(0); + } finally { + setLoading(false); + } + }, [ + channel, + endDate, + generatedEndDate, + generatedStartDate, + page, + sortBy, + sortOrder, + startDate, + ]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + setPage(1); + }, [channel, startDate, endDate, generatedStartDate, generatedEndDate, sortBy, sortOrder]); + + const openUmami = async () => { + setOpeningUmami(true); + setError(null); + try { + window.location.assign(await getUmamiSsoUrl()); + } catch (err) { + setError(err instanceof Error ? err.message : '无法打开 Umami'); + setOpeningUmami(false); + } + }; + + const overview = useMemo(() => { + const pages = rows.length; + const views = rows.reduce((sum, row) => sum + Number(row.views || 0), 0); + const visitors = rows.reduce((sum, row) => sum + Number(row.visitors || 0), 0); + const clicks = rows.reduce((sum, row) => sum + Number(row.clicks || 0), 0); + const engaged = rows.reduce((sum, row) => sum + Number(row.engagedVisits || 0), 0); + const forms = rows.reduce((sum, row) => sum + Number(row.forms || 0), 0); + return { pages, views, visitors, clicks, engaged, forms }; + }, [rows]); + + return ( +
+
+

SEO / GEO 流量

+

+ 按页面查看来自搜索引擎(SEO)与生成式引擎(GEO)的真实用户访问,表格口径与 Umami「所有生成页面明细」一致。 +

+
+ + {error &&

{error}

} + +
+
+ + + + +
+ +
+ + + + + + +
+ +
+
+
当前页合计浏览量
+ {formatNumber(overview.views)} +
+
+
筛选期总浏览量
+ {formatNumber(totalViews)} +
+
+
有 {channel.toUpperCase()} 流量的页面
+ {formatNumber(total)} +
+
+
点击 / 参与 / 表单
+ {formatNumber(overview.clicks)} / {formatNumber(overview.engaged)} / {formatNumber(overview.forms)} +
+
+ +
+ + + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => { + const pageUrl = resolvePageUrl(row); + const contribution = totalViews + ? `${((Number(row.views) / totalViews) * 100).toFixed(2)}%` + : '0.00%'; + return ( + + + + + + + + + + + + + + ); + }) + )} + +
Title页面 URL浏览量访客访问点击参与表单贡献值生成日期筛选期首次访问
正在加载…
当前日期范围内暂无 {channel.toUpperCase()} 来源的页面访问数据
{row.pageTitle} + + {pageUrl} + + {formatNumber(row.views)}{formatNumber(row.visitors)}{formatNumber(row.visits)}{formatNumber(row.clicks)}{formatNumber(row.engagedVisits)}{formatNumber(row.forms)}{contribution} + {row.generatedAt + ? `${new Date(row.generatedAt).toLocaleDateString('zh-CN')}${row.generatedAtInferred ? '(首访回推)' : ''}` + : '—'} + + {row.firstSeenAt + ? new Date(row.firstSeenAt).toLocaleString('zh-CN') + : '—'} +
+
+ +
+ + + 第 {page} / {pageCount} 页,共 {formatNumber(total)} 个页面 + + +
+
+
+ ); +} diff --git a/src/api/client.ts b/src/api/client.ts index 9dd618b..708249a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -513,9 +513,56 @@ export async function getUmamiSsoUrl(): Promise { return result.url; } +export type SeoGeoAnalyticsQuery = { + discoveryChannel: 'seo' | 'geo'; + startAt: number; + endAt: number; + timezone?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + generatedStartAt?: number; + generatedEndAt?: number; +}; + +export type SeoGeoAnalyticsPagesResult = { + discoveryChannel: 'seo' | 'geo'; + data: Array<{ + urlPath: string; + pageUrl?: string; + hostname?: string; + pageTitle: string; + views: number; + visitors: number; + visits: number; + clicks: number; + engagedVisits: number; + forms: number; + generatedAt?: string | null; + generatedAtInferred?: boolean; + firstSeenAt?: string | null; + }>; + total: number; + totalViews: number; + page: number; + pageSize: number; +}; + +export async function getSeoGeoAnalyticsPages( + query: SeoGeoAnalyticsQuery, +): Promise { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + params.set(key, String(value)); + } + }); + return portalFetch(`/admin-api/analytics/seo-geo-pages?${params.toString()}`); +} + export async function getRybbitSsoUrl(): Promise { - const result = await portalFetch<{ url: string }>('/admin-api/analytics/rybbit-sso'); - return result.url; + throw new Error('Rybbit 已退役,请使用 SEO / GEO 流量看板或 Umami'); } export async function updateMindSpaceAdminConfig(