Files
memind/mindspace-page-achievement-stats.mjs
T
john 421e204711
Memind CI / Test, build, and release guards (push) Successful in 3m28s
feat(mindspace): add achievements showcase with paginated management
Ship the M成果 page with list/delete UX, fix deletePage for incomplete page records, route /space/achievements, and add M成果 under the WeChat M空间 menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:21:42 +08:00

276 lines
9.4 KiB
JavaScript

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,
};