Files
memind/plaza-api.mjs
T
john 229805a070 Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:06:43 +08:00

569 lines
20 KiB
JavaScript

import express from 'express';
import crypto from 'node:crypto';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
import { createPlazaEventService } from './plaza-events.mjs';
import { createPlazaRecommendService } from './plaza-recommend.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
import { parseCookies } from './auth.mjs';
import {
ensureAlgorithmConfig,
loadAlgorithmConfig,
recalculateHotScores,
} from './plaza-algorithm.mjs';
import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
import { createPlazaSeoService } from './plaza-seo.mjs';
import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs';
export async function bootstrapPlaza(pool) {
await ensureAlgorithmConfig(pool);
const algorithmConfig = await loadAlgorithmConfig(pool);
const redis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
if (redis.enabled) {
console.log('Plaza Redis enabled');
}
const seo = createPlazaSeoService(pool);
const interactions = createPlazaInteractionService(pool, {
formatPostRow,
plazaRedis: redis,
});
const events = createPlazaEventService(pool);
const recommend = createPlazaRecommendService(pool, {
eventService: events,
formatPostRow,
loadViewerReactions: (viewerId, postIds) =>
interactions.loadViewerReactions(viewerId, postIds),
algorithmConfig,
});
let ops = null;
const posts = createPlazaPostService(pool, {
loadViewerReactions: (viewerId, postIds) =>
interactions.loadViewerReactions(viewerId, postIds),
plazaRedis: redis,
algorithmConfig,
recommendService: recommend,
onPostPublished: (postId) => seo?.notifyPostPublished(postId),
loadFeaturedPosts: async (viewerId) => {
if (!ops) return { homepage_banner: [], trending: [], category_top: {} };
return ops.loadActiveFeaturedPosts(viewerId);
},
});
ops = createPlazaOpsService(pool, {
formatPostRow,
reviewPost: (...args) => posts.reviewPost(...args),
invalidateFeedCaches: () => redis?.invalidateFeedCaches?.(),
});
startPlazaTasks({
pool,
plazaRedis: redis,
recalculateHotScores,
writebackPublications,
});
return { posts, interactions, events, recommend, seo, ops, redis };
}
export function createNoopPlaza() {
return { posts: null, interactions: null, seo: null, ops: null, redis: createNoopPlazaRedis() };
}
export function isPlazaPublicRead(path, method) {
if (!path.startsWith('/plaza/v1/')) return false;
if (method === 'POST' && path === '/plaza/v1/events') return true;
if (method !== 'GET') return false;
return (
path === '/plaza/v1/feed' ||
path === '/plaza/v1/categories' ||
path === '/plaza/v1/seo/sitemap' ||
/^\/plaza\/v1\/posts\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path)
);
}
export function plazaClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return forwarded.split(',')[0].trim();
}
return req.ip;
}
const PLAZA_SID_COOKIE = 'plaza_sid';
function resolvePlazaSessionId(req, res) {
const cookies = parseCookies(req.get('cookie'));
let sessionId = cookies[PLAZA_SID_COOKIE];
if (!sessionId) {
sessionId = crypto.randomUUID();
res.append(
'Set-Cookie',
`${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`,
);
}
return sessionId;
}
function recordPlazaEventsAsync(eventsService, req, res, events) {
if (!eventsService || !Array.isArray(events) || events.length === 0) return;
const sessionId = resolvePlazaSessionId(req, res);
void eventsService
.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events,
})
.catch(() => {});
}
function reactionEventType(type) {
if (type === 'like' || type === 'collect' || type === 'share') return type;
return null;
}
function plazaRouteError(res, req, error, { sendError }) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
return sendError(res, req, status, code, message, error?.details);
}
export function attachPlazaApiRoutes(api, plaza, { sendData, sendError }) {
const { posts, interactions, events, seo, ops, redis } = plaza;
function ensurePosts(res, req) {
if (!posts) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; }
return true;
}
function ensureInteractions(res, req) {
if (!interactions) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; }
return true;
}
function routeError(res, req, error) {
return plazaRouteError(res, req, error, { sendError });
}
function makeRequireOps(minRole = 'reviewer') {
return async (req, res, next) => {
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
const role = await ops.loadOperatorRole(req.currentUser.id);
if (!hasOpsRole(role, minRole)) {
return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限');
}
req.opsRole = role;
return next();
};
}
// ---- Plaza feed & posts ----
api.get('/plaza/v1/categories', async (req, res) => {
if (!ensurePosts(res, req)) return;
try {
return sendData(res, req, { categories: await posts.listCategories() });
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/seo/sitemap', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用');
try {
const data = await seo.listSitemapData({
postLimit: req.query.post_limit,
userLimit: req.query.user_limit,
});
return sendData(res, req, data);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/attribution/events', async (req, res) => {
if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
try {
const result = await seo.recordAttribution(req.body ?? {}, plazaClientIp(req));
return sendData(res, req, result, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reports', async (req, res) => {
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await ops.createReport(req.currentUser.id, {
target_type: 'post',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reports', async (req, res) => {
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await ops.createReport(req.currentUser.id, {
target_type: 'comment',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/feed', async (req, res) => {
if (!ensurePosts(res, req)) return;
try {
const sessionId = resolvePlazaSessionId(req, res);
const feed = await posts.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 routeError(res, req, error);
}
});
api.post('/plaza/v1/events', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!events) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
try {
const sessionId =
String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(req, res);
const result = await events.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events: req.body?.events ?? [],
});
return sendData(res, req, { ...result, session_id: sessionId }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.addReaction(req.currentUser.id, req.params.id, req.body?.type);
const eventType = reactionEventType(result.type);
if (eventType) {
recordPlazaEventsAsync(events, req, res, [{ event_type: eventType, post_id: req.params.id }]);
}
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.removeReaction(req.currentUser.id, req.params.id, req.params.type);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensureInteractions(res, req)) return;
try {
const comments = await interactions.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 routeError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await interactions.createComment(req.currentUser.id, req.params.id, req.body ?? {});
recordPlazaEventsAsync(events, req, res, [{ event_type: 'comment', post_id: req.params.id }]);
return sendData(res, req, { comment }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/comments/:id', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await interactions.deleteComment(req.currentUser.id, req.params.id);
return sendData(res, req, { comment });
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reactions', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const liked = req.body?.liked !== false;
const result = await interactions.toggleCommentLike(req.currentUser.id, req.params.id, liked);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug', async (req, res) => {
if (!ensureInteractions(res, req)) return;
try {
const profile = await interactions.getUserProfile(req.params.slug, req.currentUser?.id ?? null);
return sendData(res, req, profile);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug/posts', async (req, res) => {
if (!ensureInteractions(res, req)) return;
try {
const feed = await interactions.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 routeError(res, req, error);
}
});
api.post('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.followUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.unfollowUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePosts(res, req)) return;
try {
const post = await posts.getPostById(req.params.id, {
viewerId: req.currentUser?.id ?? null,
});
void redis.recordView(req.params.id, plazaClientIp(req)).catch(() => {});
recordPlazaEventsAsync(events, req, res, [{ event_type: 'view', post_id: req.params.id }]);
return sendData(res, req, { post });
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/posts', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const post = await posts.createPost(req.currentUser.id, req.body ?? {});
return sendData(res, req, { post }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.patch('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const post = await posts.updatePost(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { post });
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const post = await posts.hidePost(req.currentUser.id, req.params.id);
return sendData(res, req, { post });
} catch (error) {
return routeError(res, req, error);
}
});
// ---- Ops API ----
const opsApi = express.Router();
opsApi.use(express.json({ limit: '1mb' }));
opsApi.use(makeRequireOps('reviewer'));
opsApi.get('/review/queue', async (req, res) => {
try {
const queue = await ops.listReviewQueue({
status: req.query.status ?? 'pending_review',
cursor: req.query.cursor ?? null,
limit: req.query.limit,
keyword: req.query.keyword ?? null,
});
return sendData(res, req, queue);
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/review/posts/:id', async (req, res) => {
try {
const result = await ops.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, {
reason: req.body?.reason ?? null,
});
return sendData(res, req, { post: result });
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => {
try {
const result = await ops.batchReviewPosts(req.currentUser.id, req.body ?? {});
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/reports', async (req, res) => {
try {
return sendData(res, req, await ops.listReports({ status: req.query.status ?? 'pending' }));
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/reports/:id/process', async (req, res) => {
try {
const result = await ops.processReport(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { report: result });
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => {
try {
return sendData(res, req, await ops.listFeatured());
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => {
try {
const result = await ops.setFeatured(req.currentUser.id, req.body ?? {});
return sendData(res, req, { featured: result }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => {
try {
const result = await ops.removeFeatured(req.currentUser.id, req.params.id);
return sendData(res, req, { featured: result });
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => {
try {
return sendData(res, req, await ops.getAnalyticsOverview());
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/creators', async (req, res) => {
try {
return sendData(res, req, await ops.listCreators({ keyword: req.query.keyword ?? null }));
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.patch('/creators/:userId', async (req, res) => {
try {
const result = await ops.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {});
return sendData(res, req, { creator: result });
} catch (error) {
return routeError(res, req, error);
}
});
api.use('/ops/v1', opsApi);
}
export function attachPlazaAdminRoutes(adminApi, plaza, { sendError }) {
const { posts, ops } = plaza;
adminApi.get('/plaza/pending', async (_req, res) => {
if (!posts) return res.status(503).json({ message: 'Plaza 未启用' });
const plazaPosts = await posts.listPendingPosts();
res.json({ data: { posts: plazaPosts } });
});
adminApi.post('/plaza/posts/:id/review', async (req, res) => {
if (!posts) return res.status(503).json({ message: 'Plaza 未启用' });
try {
const action = String(req.body?.action ?? 'approve');
const reason = req.body?.reason ?? null;
const result =
ops && req.currentUser?.id
? await ops.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason })
: await posts.reviewPost(req.params.id, action, { reason });
res.json({ data: result });
} catch (error) {
const status = mapPlazaError(error);
res.status(status).json({
error: { code: error?.code ?? 'review_failed', message: error.message },
});
}
});
}