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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-12 01:34:01 +08:00
parent ed8bc6d1b1
commit 0e26db1857
10 changed files with 567 additions and 37 deletions
+17 -12
View File
@@ -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) => {
+110
View File
@@ -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}`;
}
+34
View File
@@ -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/,
);
});