import crypto from 'node:crypto'; import { parseCookies } from '../auth.mjs'; const PLAZA_SID_COOKIE = 'plaza_sid'; function assertRouter(api) { if ( !api || typeof api.get !== 'function' || typeof api.post !== 'function' || typeof api.patch !== 'function' || typeof api.delete !== 'function' ) { throw new Error( 'attachPortalPlazaRoutes requires an Express-compatible router', ); } } function reactionEventType(type) { if (type === 'like' || type === 'collect' || type === 'share') return type; return null; } export function attachPortalPlazaRoutes( api, { getPlazaSeo = () => null, getPlazaOps = () => null, getPlazaPosts = () => null, getPlazaEvents = () => null, getPlazaInteractions = () => null, getPlazaRedis = () => null, resolveClientIp = (req) => req.ip, randomUUID = crypto.randomUUID, sendData = (res, _req, data, status = 200) => res.status(status).json({ data }), sendError = (res, _req, status, code, message, details) => res.status(status).json({ error: { code, message, details } }), handleRouteError = (_res, _req, error) => { throw error; }, } = {}, ) { assertRouter(api); const resolveSessionId = (req, res) => { const cookies = parseCookies(req.get('cookie')); let sessionId = cookies[PLAZA_SID_COOKIE]; if (!sessionId) { sessionId = randomUUID(); res.append( 'Set-Cookie', `${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`, ); } return sessionId; }; const recordEventsAsync = (req, res, events) => { const plazaEvents = getPlazaEvents(); if (!plazaEvents || !Array.isArray(events) || events.length === 0) return; const sessionId = resolveSessionId(req, res); void plazaEvents .recordEvents({ userId: req.currentUser?.id ?? null, sessionId, events, }) .catch(() => {}); }; const requirePosts = (res, req) => { const plazaPosts = getPlazaPosts(); if (!plazaPosts) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return null; } return plazaPosts; }; const requireInteractions = (res, req) => { const plazaInteractions = getPlazaInteractions(); if (!plazaInteractions) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return null; } return plazaInteractions; }; const requireCurrentUser = (req, res) => { if (req.currentUser) return req.currentUser; sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); return null; }; api.post('/plaza/v1/attribution/events', async (req, res) => { const plazaSeo = getPlazaSeo(); if (!plazaSeo) { return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); } try { const result = await plazaSeo.recordAttribution( req.body ?? {}, resolveClientIp(req), ); return sendData(res, req, result, 201); } catch (error) { return handleRouteError(res, req, error); } }); for (const [route, targetType] of [ ['/plaza/v1/posts/:id/reports', 'post'], ['/plaza/v1/comments/:id/reports', 'comment'], ]) { api.post(route, async (req, res) => { const plazaOps = getPlazaOps(); if (!plazaOps) { return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); } const user = requireCurrentUser(req, res); if (!user) return; try { const report = await plazaOps.createReport(user.id, { target_type: targetType, target_id: req.params.id, reason: req.body?.reason, detail: req.body?.detail, }); return sendData(res, req, { report }, 201); } catch (error) { return handleRouteError(res, req, error); } }); } api.get('/plaza/v1/feed', async (req, res) => { const plazaPosts = requirePosts(res, req); if (!plazaPosts) return; try { const sessionId = resolveSessionId(req, res); const feed = await plazaPosts.listFeed({ sort: req.query.sort, categorySlug: req.query.category ?? null, cursor: req.query.cursor ?? null, limit: req.query.limit, viewerId: req.currentUser?.id ?? null, sessionId, }); return sendData(res, req, feed); } catch (error) { return handleRouteError(res, req, error); } }); api.post('/plaza/v1/events', async (req, res) => { const plazaPosts = requirePosts(res, req); if (!plazaPosts) return; const plazaEvents = getPlazaEvents(); if (!plazaEvents) { return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); } try { const sessionId = String(req.body?.session_id ?? '').trim() || resolveSessionId(req, res); const result = await plazaEvents.recordEvents({ userId: req.currentUser?.id ?? null, sessionId, events: req.body?.events ?? [], }); return sendData( res, req, { ...result, session_id: sessionId }, 201, ); } catch (error) { return handleRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/reactions', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; const user = requireCurrentUser(req, res); if (!user) return; try { const result = await plazaInteractions.addReaction( user.id, req.params.id, req.body?.type, ); const eventType = reactionEventType(result.type); if (eventType) { recordEventsAsync(req, res, [ { event_type: eventType, post_id: req.params.id }, ]); } return sendData(res, req, result); } catch (error) { return handleRouteError(res, req, error); } }); api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; const user = requireCurrentUser(req, res); if (!user) return; try { const result = await plazaInteractions.removeReaction( user.id, req.params.id, req.params.type, ); return sendData(res, req, result); } catch (error) { return handleRouteError(res, req, error); } }); api.get('/plaza/v1/posts/:id/comments', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; try { const comments = await plazaInteractions.listComments(req.params.id, { cursor: req.query.cursor ?? null, limit: req.query.limit, parentId: req.query.parent_id ?? null, viewerId: req.currentUser?.id ?? null, }); return sendData(res, req, comments); } catch (error) { return handleRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/comments', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; const user = requireCurrentUser(req, res); if (!user) return; try { const comment = await plazaInteractions.createComment( user.id, req.params.id, req.body ?? {}, ); recordEventsAsync(req, res, [ { event_type: 'comment', post_id: req.params.id }, ]); return sendData(res, req, { comment }, 201); } catch (error) { return handleRouteError(res, req, error); } }); api.delete('/plaza/v1/comments/:id', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; const user = requireCurrentUser(req, res); if (!user) return; try { const comment = await plazaInteractions.deleteComment( user.id, req.params.id, ); return sendData(res, req, { comment }); } catch (error) { return handleRouteError(res, req, error); } }); api.post('/plaza/v1/comments/:id/reactions', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; const user = requireCurrentUser(req, res); if (!user) return; try { const liked = req.body?.liked !== false; const result = await plazaInteractions.toggleCommentLike( user.id, req.params.id, liked, ); return sendData(res, req, result); } catch (error) { return handleRouteError(res, req, error); } }); api.get('/plaza/v1/users/:slug', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; try { const profile = await plazaInteractions.getUserProfile( req.params.slug, req.currentUser?.id ?? null, ); return sendData(res, req, profile); } catch (error) { return handleRouteError(res, req, error); } }); api.get('/plaza/v1/users/:slug/posts', async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; try { const feed = await plazaInteractions.listUserPosts(req.params.slug, { cursor: req.query.cursor ?? null, limit: req.query.limit, viewerId: req.currentUser?.id ?? null, }); return sendData(res, req, feed); } catch (error) { return handleRouteError(res, req, error); } }); for (const [route, operation] of [ ['/plaza/v1/users/:slug/follow', 'followUser'], ['/plaza/v1/users/:slug/follow', 'unfollowUser'], ]) { const register = operation === 'followUser' ? api.post.bind(api) : api.delete.bind(api); register(route, async (req, res) => { const plazaInteractions = requireInteractions(res, req); if (!plazaInteractions) return; const user = requireCurrentUser(req, res); if (!user) return; try { const result = await plazaInteractions[operation]( user.id, req.params.slug, ); return sendData(res, req, result); } catch (error) { return handleRouteError(res, req, error); } }); } api.get('/plaza/v1/posts/:id', async (req, res) => { const plazaPosts = requirePosts(res, req); if (!plazaPosts) return; try { const post = await plazaPosts.getPostById(req.params.id, { viewerId: req.currentUser?.id ?? null, }); void getPlazaRedis() .recordView(req.params.id, resolveClientIp(req)) .catch(() => {}); recordEventsAsync(req, res, [ { event_type: 'view', post_id: req.params.id }, ]); return sendData(res, req, { post }); } catch (error) { return handleRouteError(res, req, error); } }); api.post('/plaza/v1/posts', async (req, res) => { const plazaPosts = requirePosts(res, req); if (!plazaPosts) return; const user = requireCurrentUser(req, res); if (!user) return; try { const post = await plazaPosts.createPost(user.id, req.body ?? {}); return sendData(res, req, { post }, 201); } catch (error) { return handleRouteError(res, req, error); } }); api.patch('/plaza/v1/posts/:id', async (req, res) => { const plazaPosts = requirePosts(res, req); if (!plazaPosts) return; const user = requireCurrentUser(req, res); if (!user) return; try { const post = await plazaPosts.updatePost( user.id, req.params.id, req.body ?? {}, ); return sendData(res, req, { post }); } catch (error) { return handleRouteError(res, req, error); } }); api.delete('/plaza/v1/posts/:id', async (req, res) => { const plazaPosts = requirePosts(res, req); if (!plazaPosts) return; const user = requireCurrentUser(req, res); if (!user) return; try { const post = await plazaPosts.hidePost(user.id, req.params.id); return sendData(res, req, { post }); } catch (error) { return handleRouteError(res, req, error); } }); }