421e204711
Memind CI / Test, build, and release guards (push) Successful in 3m28s
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>
276 lines
9.4 KiB
JavaScript
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,
|
|
};
|