import crypto from 'node:crypto'; import { pseudonymizeAnalyticsId, resolveAnalyticsIdentity, resolveMindSpaceAnalyticsConfig, } from './mindspace-analytics.mjs'; 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 = resolveMindSpaceAnalyticsConfig(env)) { const baseUrl = normalizeBaseUrl(analyticsConfig?.analyticsUrl || env.MEMIND_ANALYTICS_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 sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim(); const websiteId = String(analyticsConfig?.websiteId || env.MEMIND_ANALYTICS_WEBSITE_ID || '').trim(); return { baseUrl, username, password, sharedSecret, websiteId }; } async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) { const encoded = Buffer.from( JSON.stringify({ username, exp: Math.floor(Date.now() / 1000) + 60, nonce: crypto.randomUUID(), }), ).toString('base64url'); const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url'); const ticket = `${encoded}.${signature}`; const response = await fetch(`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`, { signal: AbortSignal.timeout(10_000), }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`); } const token = String(body?.token ?? '').trim(); if (!token) throw new Error('Umami SSO 登录响应缺少 token'); return token; } async function loginUmami(credentials) { if (credentials.sharedSecret) { return loginUmamiViaSso(credentials); } if (!credentials.password) { throw new Error('未配置 Umami 凭据'); } const response = await fetch(`${credentials.baseUrl}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: credentials.username, password: credentials.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; } function pathnameFromUrl(value) { const raw = String(value ?? '').trim(); if (!raw) return ''; try { return new URL(raw, 'https://m.tkmind.cn').pathname.replace(/\/$/, '') || '/'; } catch { return raw.startsWith('/') ? raw.replace(/\/$/, '') || '/' : ''; } } function buildPageMatchKeys(page, userId) { const keys = new Set(); if (page?.id) keys.add(String(page.id)); const workspacePath = String(page?.workspaceRelativePath ?? '').trim().replace(/^\/+/, ''); if (workspacePath) { keys.add(`/MindSpace/${userId}/${workspacePath}`.replace(/\/$/, '')); } for (const candidate of [page?.publicationUrl, page?.workspacePublicUrl]) { const pathname = pathnameFromUrl(candidate); if (pathname) keys.add(pathname); } return keys; } export function mergeAchievementStatsForPage(page, umamiByPath, userId) { const publicationViews = Number(page?.viewCount ?? 0); const keys = buildPageMatchKeys(page, userId); let umamiViews = 0; let umamiClicks = 0; let matched = false; for (const key of keys) { const stats = umamiByPath.get(key); if (!stats) continue; matched = true; umamiViews = Math.max(umamiViews, Number(stats.views ?? 0)); umamiClicks = Math.max(umamiClicks, Number(stats.clicks ?? 0)); } const viewCount = matched ? Math.max(publicationViews, umamiViews) : publicationViews; const clickCount = matched ? umamiClicks : null; let statsSource = 'none'; if (matched && (umamiViews > 0 || umamiClicks > 0)) statsSource = 'umami'; else if (publicationViews > 0) statsSource = 'publication'; return { ...page, viewCount, clickCount, statsSource, }; } export async function fetchUmamiPageStatsByPath({ ownerUserId, env = process.env, analyticsConfig = resolveMindSpaceAnalyticsConfig(env), logger = console, } = {}) { if (!analyticsConfig?.enabled || !ownerUserId) return new Map(); const credentials = resolveUmamiCredentials(env, analyticsConfig); if (!credentials.websiteId) return new Map(); if (!credentials.sharedSecret && !credentials.password) return new Map(); const ownerIdentity = resolveAnalyticsIdentity(ownerUserId, analyticsConfig); if (!ownerIdentity) return new Map(); try { const token = await getUmamiAuthToken(credentials); const params = new URLSearchParams(); params.set('page', '1'); params.set('pageSize', '100'); params.set('sortBy', 'generatedAt'); params.set('sortOrder', 'desc'); const endAt = Date.now(); const startAt = endAt - 365 * 24 * 60 * 60 * 1000; params.set('startAt', String(startAt)); params.set('endAt', String(endAt)); 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})`); } const rows = Array.isArray(body?.data) ? body.data : []; const map = new Map(); for (const row of rows) { if (String(row?.ownerId ?? '') !== ownerIdentity) continue; const urlPath = pathnameFromUrl(row?.urlPath); const pageUrlPath = pathnameFromUrl(row?.pageUrl); const stats = { views: Number(row?.views ?? 0), clicks: Number(row?.clicks ?? 0), }; if (urlPath) map.set(urlPath, stats); if (pageUrlPath) map.set(pageUrlPath, stats); } return map; } catch (error) { logger?.warn?.( '[MindSpace] Umami achievement stats unavailable:', error instanceof Error ? error.message : error, ); return new Map(); } } export async function loadPublicationStatsByPageIds(pool, userId, pageIds) { if (!pool || !userId || !Array.isArray(pageIds) || pageIds.length === 0) { return new Map(); } const ids = [...new Set(pageIds.map((id) => String(id ?? '').trim()).filter(Boolean))]; if (ids.length === 0) return new Map(); const placeholders = ids.map(() => '?').join(','); const [rows] = await pool.query( `SELECT page_id, SUM(view_count) AS total_view_count, SUBSTRING_INDEX( GROUP_CONCAT(id ORDER BY COALESCE(published_at, 0) DESC, updated_at DESC), ',', 1 ) AS latest_publish_id FROM h5_publish_records WHERE user_id = ? AND page_id IN (${placeholders}) GROUP BY page_id`, [userId, ...ids], ); const map = new Map(); for (const row of rows) { map.set(String(row.page_id), { viewCount: Number(row.total_view_count ?? 0), publicationId: row.latest_publish_id ?? null, }); } return map; } function applyPublicationStats(pages, pubStatsByPageId) { if (!pubStatsByPageId?.size) return pages; return pages.map((page) => { const pubStats = pubStatsByPageId.get(String(page?.id ?? '')); if (!pubStats) return page; const existingViews = Number(page?.viewCount ?? 0); const mergedViews = Math.max(existingViews, pubStats.viewCount); if (mergedViews === existingViews && (page?.publicationId || !pubStats.publicationId)) { return page; } return { ...page, viewCount: mergedViews, ...(page?.publicationId ? {} : pubStats.publicationId ? { publicationId: pubStats.publicationId } : {}), ...(mergedViews > 0 && page?.statsSource === 'none' ? { statsSource: 'publication' } : {}), }; }); } export async function enrichPagesWithAchievementStats(pages, { ownerUserId, env = process.env, analyticsConfig = resolveMindSpaceAnalyticsConfig(env), pool = null, logger = console, skipUmami = false, } = {}) { const pageIds = pages.map((page) => page?.id).filter(Boolean); const pubStatsByPageId = await loadPublicationStatsByPageIds(pool, ownerUserId, pageIds); const pagesWithPublicationStats = applyPublicationStats(pages, pubStatsByPageId); if (skipUmami) { return pagesWithPublicationStats.map((page) => mergeAchievementStatsForPage(page, new Map(), ownerUserId), ); } const umamiByPath = await fetchUmamiPageStatsByPath({ ownerUserId, env, analyticsConfig, logger, }); return pagesWithPublicationStats.map((page) => mergeAchievementStatsForPage(page, umamiByPath, ownerUserId)); } export const achievementStatsInternals = { buildPageMatchKeys, mergeAchievementStatsForPage, pathnameFromUrl, pseudonymizeAnalyticsId, };