import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs'; import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs'; const DEFAULT_PUBLIC_HOST = 'm.tkmind.cn'; const SORT_COLUMNS = new Set([ 'publishedAt', 'pageTitle', 'ownerLabel', 'totalViews', 'seoViews', 'geoViews', 'crawlerViews', 'lifetimeViewCount', ]); function asPositiveInt(value, fallback) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 1) return fallback; return Math.floor(parsed); } function normalizeSortBy(value, fallback = 'publishedAt') { const sortBy = String(value ?? '').trim(); return SORT_COLUMNS.has(sortBy) ? sortBy : fallback; } function normalizeSortOrder(value, fallback = 'desc') { return String(value ?? '').trim().toLowerCase() === 'asc' ? 'asc' : fallback; } export function resolveAdminCatalogPublicUrl(publicUrl, { publicHost = DEFAULT_PUBLIC_HOST } = {}) { const raw = String(publicUrl ?? '').trim(); if (!raw) return ''; if (/^https?:\/\//i.test(raw)) return raw.split('#')[0]; if (raw.startsWith('/')) return `https://${publicHost}${raw}`.split('#')[0]; return raw.split('#')[0]; } function compareRows(a, b, sortBy, sortOrder) { const direction = sortOrder === 'asc' ? 1 : -1; const left = a[sortBy]; const right = b[sortBy]; if (typeof left === 'string' || typeof right === 'string') { return String(left ?? '').localeCompare(String(right ?? ''), 'zh-CN') * direction; } return ((Number(left) || 0) - (Number(right) || 0)) * direction; } function aggregateViewStats(rows = []) { const stats = { totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0, }; for (const row of rows) { const views = Number(row.views) || 0; if (views <= 0) continue; stats.totalViews += views; if (String(row.device_type ?? '') === 'bot') { stats.crawlerViews += views; continue; } const discovery = classifyReferrerHost(row.referrer_host); if (discovery.discovery_channel === 'seo') stats.seoViews += views; if (discovery.discovery_channel === 'geo') stats.geoViews += views; } return stats; } export async function listAdminSeoGeoPublicationCatalog( pool, { page = 1, pageSize = 20, sortBy = 'publishedAt', sortOrder = 'desc', startAt = null, endAt = null, search = '', publicHost = DEFAULT_PUBLIC_HOST, now = Date.now(), } = {}, ) { if (!pool) { return { data: [], total: 0, page: 1, pageSize: 20, totals: emptyTotals() }; } const safePage = asPositiveInt(page, 1); const safePageSize = Math.min(asPositiveInt(pageSize, 20), 100); const normalizedSortBy = normalizeSortBy(sortBy); const normalizedSortOrder = normalizeSortOrder(sortOrder); const searchTerm = String(search ?? '').trim(); const rangeStart = Number(startAt); const rangeEnd = Number(endAt); const hasRange = Number.isFinite(rangeStart) && Number.isFinite(rangeEnd) && rangeEnd >= rangeStart; const params = []; let whereSql = `WHERE pr.status = 'online'`; if (searchTerm) { whereSql += ` AND ( p.title LIKE ? OR u.username LIKE ? OR u.display_name LIKE ? OR pr.public_url LIKE ? )`; const like = `%${searchTerm}%`; params.push(like, like, like, like); } const [countRows] = await pool.query( `SELECT COUNT(*) AS total FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_users u ON u.id = pr.user_id ${whereSql}`, params, ); const total = Number(countRows[0]?.total ?? 0); const [publicationRows] = await pool.query( `SELECT pr.id AS publicationId, pr.page_id AS pageId, pr.user_id AS userId, pr.public_url AS publicUrl, pr.access_mode AS accessMode, pr.status, pr.user_confirmed_at AS userConfirmedAt, pr.expires_at AS expiresAt, pr.published_at AS publishedAt, pr.view_count AS lifetimeViewCount, p.title AS pageTitle, p.summary AS pageSummary, u.username, u.display_name AS displayName FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_users u ON u.id = pr.user_id ${whereSql}`, params, ); const publicationIds = publicationRows.map((row) => row.publicationId); const statsByPublication = new Map(); if (publicationIds.length > 0 && hasRange) { const placeholders = publicationIds.map(() => '?').join(', '); const [viewRows] = await pool.query( `SELECT publish_id AS publicationId, device_type, referrer_host, COUNT(*) AS views FROM h5_publication_views WHERE publish_id IN (${placeholders}) AND viewed_at >= ? AND viewed_at <= ? GROUP BY publish_id, device_type, referrer_host`, [...publicationIds, rangeStart, rangeEnd], ); for (const row of viewRows) { const key = String(row.publicationId); const bucket = statsByPublication.get(key) ?? []; bucket.push(row); statsByPublication.set(key, bucket); } } const rows = publicationRows.map((row) => { const publication = normalizePublicationSnapshot({ id: row.publicationId, pageId: row.pageId, publicUrl: row.publicUrl, accessMode: row.accessMode, status: row.status, userConfirmedAt: row.userConfirmedAt, expiresAt: row.expiresAt, title: row.pageTitle, summary: row.pageSummary, }); const stats = hasRange ? aggregateViewStats(statsByPublication.get(String(row.publicationId)) ?? []) : { totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0, }; const ownerLabel = String(row.displayName ?? '').trim() || String(row.username ?? '').trim() || '未命名用户'; return { publicationId: row.publicationId, pageId: row.pageId, userId: row.userId, ownerLabel, username: String(row.username ?? '').trim(), pageTitle: String(row.pageTitle ?? '').trim() || '未命名页面', pageSummary: String(row.pageSummary ?? '').trim(), publicUrl: resolveAdminCatalogPublicUrl(row.publicUrl, { publicHost }), accessMode: row.accessMode, indexable: isPublicationIndexable(publication, { now }), publishedAt: Number(row.publishedAt) || null, lifetimeViewCount: Number(row.lifetimeViewCount) || 0, ...stats, }; }); rows.sort((a, b) => { const primary = compareRows(a, b, normalizedSortBy, normalizedSortOrder); if (primary !== 0) return primary; return compareRows(a, b, 'pageTitle', 'asc'); }); const offset = (safePage - 1) * safePageSize; const pageRows = rows.slice(offset, offset + safePageSize); const totals = rows.reduce( (acc, row) => ({ totalViews: acc.totalViews + row.totalViews, seoViews: acc.seoViews + row.seoViews, geoViews: acc.geoViews + row.geoViews, crawlerViews: acc.crawlerViews + row.crawlerViews, }), emptyTotals(), ); return { data: pageRows, total, page: safePage, pageSize: safePageSize, totals, }; } function emptyTotals() { return { totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0, }; }