import express from 'express'; import crypto from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import { createProxyMiddleware } from 'http-proxy-middleware'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { AUTH_COOKIE, clearSessionCookie, createAuthManager, parseCookies, sessionCookie, } from './auth.mjs'; import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs'; import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { createToolGateway } from './tool-gateway.mjs'; import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs'; import { createImageMakeClientFromEnv } from './image-make-client.mjs'; import { createMindSpaceImageGenerationService } from './mindspace-image-generation.mjs'; import { createMindSpaceImageReviewService } from './mindspace-image-review.mjs'; import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs'; import { createAgentRunGateway } from './agent-run-gateway.mjs'; import { createAgentRunEventsHandler, createGetAgentRunHandler, createPostAgentRunsHandler, } from './agent-run-routes.mjs'; import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs'; import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; import { isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs'; import { clearUserLogoutCookies, clearUserSessionCookie, createUserAuth, resolveCookieDomainForRequest, USER_COOKIE, userLoginCookies, userSessionCookie, } from './user-auth.mjs'; import { createWikiAuth } from './wiki-auth.mjs'; import { isLocalDevHostname } from './scripts/local-test-config.mjs'; import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveMindSpaceAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs'; import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs'; import { attachRequestId, sendData, sendError } from './api-response.mjs'; import { createNotificationDispatcher } from './notification-dispatcher.mjs'; import { createMindSpaceAuditWriter } from './mindspace-audit.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; import { createMindSpaceService } from './mindspace.mjs'; import { ensureMindSpaceConfig, loadMindSpaceConfig } from './mindspace-config.mjs'; import { createMindSearchConfigService } from './mindsearch-config.mjs'; import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { createDownloadConversationPackageArtifactHandler, createDownloadConversationPackageManifestHandler, createGetConversationPackageHandler, } from './mindspace-conversation-package-routes.mjs'; import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs'; import { assertMindSpaceServerAdapterContract, createMindSpaceServerAdapter, } from './mindspace-server-adapter.mjs'; import { resolveMindSpacePublicRequest, } from './mindspace-public-route.mjs'; import { buildPublishedHtmlViewContext, injectPublishedPageDataContext, parseMindSpacePublishFilePath, resolvePublicRequestOrigin, } from './mindspace-public-page-context.mjs'; import { preparePublicHtmlAssetDelivery, verifyPublicAssetToken, } from './mindspace-public-asset-token.mjs'; import { collectInlineScriptHashes, decorateMindSpacePublishedHtml, handleMindSpaceLongImageDownload, } from './mindspace-public-delivery.mjs'; import { buildMindSpacePublicRoutePath, buildMindSpacePublicUrlForUser, isPublicWorkspaceHtmlRelativePath, resolveMindSpaceRuntimeConfig, resolveMindSpacePublishRoot, resolveMindSpaceServerRuntimeOptions, resolveMindSpaceUserPublishDir, } from './mindspace-runtime-config.mjs'; import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { publicationInternals, rewriteBrokenMindSpacePublicImageUrls, rewritePublicationCanonicalAssetUrls, rewriteWorkspacePublicAssetReferences, } from './mindspace-publications.mjs'; 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 { 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 } from './plaza-ops.mjs'; import { createWordFilterService } from './word-filter.mjs'; import { allowPlazaEmbedFrame, preparePublicationHtmlForEmbed, isPlazaEmbedRequest, stripPublicationHtmlCspMeta, } from './plaza-embed.mjs'; import { publishedPageCsp } from './mindspace-published-page-csp.mjs'; import { rewriteKnownCdnScriptSources } from './mindspace-published-script-localize.mjs'; import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs'; import { analyzeChatMessageForSave, buildChatSavePreviewFrameUrl, buildChatSaveThumbnailUrl, buildWorkspaceAssetUrl, buildWorkspaceBaseHref, buildWorkspaceThumbnailUrl, injectHtmlBaseHref, resolveClosestHtmlRelativePath, createAssetDataUriResolver, htmlReferencesPrivateAssets, materializePrivateAssetsInWorkspaceHtml, repairMissingHtmlAssetReferences, resolveChatSaveAnalysis, resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { collectPublicHtmlWritePathsFromSessionEvent, materializePublicHtmlWritesFromSessionEvent, normalizePublicHtmlRelativePath, syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; import { getPageDeliveryContract, markPageDeliveryContractReady, preparePageDeliveryContract } from './mindspace-delivery-contract.mjs'; import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs'; import { evaluatePageDataHtmlContent, maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs'; import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs'; import { detectPageDataDatasetUsageFromHtml } from './page-data-html-detect.mjs'; import { readPageAccessPolicy } from './page-data-policy-store.mjs'; import { policyAllowsAction } from './page-access-policy.mjs'; import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs'; import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs'; import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs'; import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { extractSharePreviewMeta, injectOgTags, injectWechatShareBridge, renderWechatSharePreviewHtml, } from './mindspace-og-tags.mjs'; import { ensureThumbnailPng, rasterizeThumbnailSvgToPng, thumbnailPngPathForSvg, } from './mindspace-thumbnail-png.mjs'; import { isLongImageDownloadRequest, longImagePathForHtml, renderLongImage, renderLongImageBuffer, } from './mindspace-long-image.mjs'; import { generateDocxBuffer } from './mindspace-docx-export.mjs'; import { DOCX_MIME_TYPE, registerChatDocxArtifactForConversation, } from './mindspace-chat-docx-package.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; import { scanWorkspaceFilesForProhibitedBrowserStorage } from './mindspace-browser-storage-policy.mjs'; import { listRecentlyModifiedPublicHtmlRelativePaths } from './mindspace-run-public-html-scope.mjs'; import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs'; import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs'; import { createRechargeService } from './billing-recharge.mjs'; import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs'; import { createWechatPayClient, loadWechatPayConfig, WECHAT_NOTIFY_SUCCESS_V2, } from './wechat-pay.mjs'; import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; import { loadWechatMpModule } from './wechat-mp-loader.mjs'; import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; import { createScheduleService } from './schedule-service.mjs'; import { createFeedbackService } from './user-feedback.mjs'; import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs'; import { filterUserVisibleConversation, repairSessionConversationFromDb } from './conversation-repair.mjs'; import { filterNonemptyUserVisibleMessages } from './conversation-transcript-persist.mjs'; import { createSessionStreamStore } from './session-stream-store.mjs'; import { isSessionStreamReplayEnabled } from './session-stream.mjs'; import { createManagedChatIntentRouter, resolveNormalizedRouterDecisionMode } from './chat-intent-router.mjs'; import { createSessionSnapshotService } from './session-snapshot.mjs'; import { createConversationMemoryService } from './conversation-memory.mjs'; import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createExperienceService } from './experience-service.mjs'; import { attachAsrRoutes } from './asr-proxy.mjs'; import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs'; import { createPageDataService } from './page-data-service.mjs'; import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs'; import { isNativeH5ApiPath } from './policies.mjs'; import { applyMemindRuntimeProfile, describeMemindRuntimeProfile, loadMemindEnvFiles, } from './scripts/memind-runtime-profile.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); loadMemindEnvFiles(__dirname); applyMemindRuntimeProfile({ rootDir: __dirname }); function parseApiTargets() { const csvTargets = (process.env.TKMIND_API_TARGETS ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean); const legacyTargets = [ process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', process.env.TKMIND_API_TARGET_1, ].filter(Boolean); return [...new Set([...csvTargets, ...legacyTargets])]; } const PORT = Number(process.env.H5_PORT ?? 8081); const HOST = String(process.env.H5_HOST ?? '127.0.0.1').trim() || '127.0.0.1'; const API_TARGETS = parseApiTargets(); const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006'; const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env); const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; const WECHAT_MP_CONFIG = loadWechatMpConfig(); // 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0, // 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。 const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0'; const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users'); const app = express(); app.set('trust proxy', 1); const isSecureRequest = (req) => req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https'; const msFlags = mindspaceFlags(); app.use(attachRequestId); app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); if (isSecureRequest(req)) { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } next(); }); function isWechatMiniProgramSource(value) { if (!value) return false; try { return new URL(value).hostname.endsWith('servicewechat.com'); } catch { return false; } } function csrfOriginCheck(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); const host = req.get('host'); const origin = req.get('origin'); const referer = req.get('referer'); if (!origin && !referer) return next(); const requestHostname = (host ?? '').split(':')[0]; if ([origin, referer].some(isWechatMiniProgramSource)) { return next(); } const allowed = [origin, referer].some((value) => { if (!value) return false; try { const source = new URL(value); if (source.host === host) return true; return ( isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname) ); } catch { return false; } }); if (!allowed) { return sendError(res, req, 403, 'csrf_failed', '来源校验失败'); } return next(); } app.use('/api', csrfOriginCheck); app.use('/auth', csrfOriginCheck); app.use('/mindspace', csrfOriginCheck); const jsonBody = express.json({ limit: '1mb' }); const jsonUnlessMultipart = (req, res, next) => { if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) return next(); return jsonBody(req, res, next); }; const rawUploadBody = express.raw({ type: 'application/octet-stream', limit: Math.max(mindSpaceServerRuntime.maxFileBytes, DEFAULT_IMAGE_UPLOAD_MAX_BYTES), }); const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), 'wiki-db')); let legacyAuth = null; if (ACCESS_PASSWORD && !isDatabaseConfigured()) { legacyAuth = createAuthManager({ password: ACCESS_PASSWORD }); } else if (ACCESS_PASSWORD && isDatabaseConfigured()) { console.log('H5_ACCESS_PASSWORD ignored: multi-user database auth is configured'); } let userAuth = null; let sessionAccess = null; let sessionStreamStore = null; let tkmindProxy = null; async function resolveActiveSessionAccess() { if (sessionAccess) return sessionAccess; if (!userAuth) return null; return createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() }); } async function ownsAgentSession(userId, sessionId) { if (!userId || !sessionId) return false; const access = await resolveActiveSessionAccess(); if (!access) return false; return access.validateOwnership(userId, sessionId); } async function unregisterAgentSessionForUser(userId, sessionId) { if (!userId || !sessionId) return; const access = await resolveActiveSessionAccess(); if (!access) return; await access.unregisterSession({ userId, sessionId }); } let agentRunGateway = null; const sessionPageDeliveryLocks = new Map(); function beginSessionPageDelivery(sessionId) { sessionPageDeliveryLocks.set(sessionId, Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) + 1); } function endSessionPageDelivery(sessionId) { const remaining = Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) - 1; if (remaining > 0) sessionPageDeliveryLocks.set(sessionId, remaining); else sessionPageDeliveryLocks.delete(sessionId); } let chatIntentRouter = null; let toolGateway = null; let assetGatewayConfigService = null; let imageMakeAdminConfigService = null; let mindSpaceImageGeneration = null; let directChatService = null; let sessionSnapshotService = null; let conversationMemoryService = null; let memoryV2 = null; let memoryV2ConfigService = null; let skillRuntimeConfigService = null; let wechatScheduleLlmConfigService = null; let mindSpace = null; let mindSpaceAssets = null; let mindSpaceAudit = null; let mindSpaceServiceFacade = null; let mindSpaceConversationPackageRegistry = null; let mindSpacePages = null; let mindSpacePageSync = null; let mindSpacePageLiveEdit = null; let mindSpaceAssetAgent = null; let mindSpacePageEditSession = null; let mindSpacePublications = null; let workspacePageDeliver = null; let plazaPosts = null; let plazaEvents = null; let plazaRecommend = null; let plazaInteractions = null; let plazaSeo = null; let plazaOps = null; let plazaRedis = createNoopPlazaRedis(); let mindSpaceCleanup = null; let mindSpaceAgentJobs = null; let mindSpaceAgentRunner = null; let rechargeService = null; let subscriptionService = null; let wechatPayClient = null; let wechatOAuthService = null; let wechatMpService = null; let notificationDispatcher = null; let scheduleService = null; let feedbackService = null; let scheduleReminderWorker = null; let llmProviderService = null; let wordFilterService = null; let authPool = null; let pageDataService = null; let pageDataPublicService = null; let mindSpaceAnalyticsConfig = resolveMindSpaceAnalyticsConfig(); async function bootstrapUserAuth() { try { if (!isDatabaseConfigured()) return false; const pool = createDbPool(); const mindSearchConfigService = createMindSearchConfigService(pool); await mindSearchConfigService.ensureSchema(); authPool = pool; await initSchema(pool); await ensureMindSpaceConfig(pool, { env: process.env, }); const storedMindSpaceConfig = await loadMindSpaceConfig(pool, { env: process.env, includeAnalyticsSecret: true, }); if (storedMindSpaceConfig?.analytics) { mindSpaceAnalyticsConfig = { ...mindSpaceAnalyticsConfig, ...storedMindSpaceConfig.analytics, enabled: Boolean(storedMindSpaceConfig.analytics.enabled && storedMindSpaceConfig.analytics.websiteId && storedMindSpaceConfig.analytics.idSecret), hostPath: '/analytics', scriptPath: '/analytics/script.js', }; } scheduleService = createScheduleService(pool, { defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', }); pageDataService = createPageDataService({ getUserAuth: () => userAuth, getPool: () => authPool, resolveWorkspaceRoot: async (user) => { if (user?.workspaceRoot) return user.workspaceRoot; if (!userAuth || !user?.id) return null; return userAuth.resolveWorkingDir(user.id); }, }); pageDataPublicService = createPageDataPublicService({ getPool: () => authPool, resolveH5Root: () => __dirname, }); feedbackService = createFeedbackService(pool); mindSpace = createMindSpaceService(pool, { maxFileBytes: mindSpaceServerRuntime.maxFileBytes, aiDailyLimit: mindSpaceServerRuntime.aiDailyLimit, publicPageLimit: mindSpaceServerRuntime.publicPageLimit, monthlyViewLimit: mindSpaceServerRuntime.monthlyViewLimit, scheduleService, }); const resolveUserIdForAgentSession = async (sessionId) => { const [rows] = await pool.query( `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, [sessionId], ); return rows[0]?.user_id ?? null; }; const mindSpaceRuntimeAdapter = assertMindSpaceServerAdapterContract( createMindSpaceServerAdapter({ pool, h5Root: __dirname, env: process.env, maxFileBytes: mindSpaceServerRuntime.maxFileBytes, publicPageLimit: mindSpaceServerRuntime.publicPageLimit, resolveUserIdForAgentSession, resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId), resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId), registerPublicHtmlArtifactsForConversation, syncWorkspaceAssetsEnabled: WORKSPACE_MAINTENANCE_ENABLED, remote: mindSpaceServerRuntime.remote, logger: console, }), ); mindSpaceRuntimeAdapter.assertReady?.(); mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade; mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry; mindSpaceAssets = mindSpaceRuntimeAdapter.assetService; mindSpacePages = mindSpaceRuntimeAdapter.pageService; mindSpacePageSync = mindSpaceRuntimeAdapter.pageSyncService; mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService; mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService; mindSpacePublications = mindSpaceRuntimeAdapter.publicationService; workspacePageDeliver = createWorkspacePageDeliverService({ pool, pageService: mindSpacePages, publicationService: mindSpacePublications, pageSyncService: mindSpacePageSync, pageDataEnsure: { ensurePageDataHtmlPagesBound }, h5Root: __dirname, storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, findPageByRelativePath: mindSpacePages.findPageByRelativePath.bind(mindSpacePages), logger: console, }); const resolveUserIdByDirKey = async (dirKey) => { let userId = dirKey; if (!PUBLISH_KEY_UUID.test(dirKey)) { const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [dirKey]); userId = rows[0]?.id; } return userId ?? null; }; await ensureAlgorithmConfig(pool); const plazaAlgorithmConfig = await loadAlgorithmConfig(pool); plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool); if (plazaRedis.enabled) { console.log('Plaza Redis enabled'); } plazaSeo = createPlazaSeoService(pool); plazaInteractions = createPlazaInteractionService(pool, { formatPostRow, plazaRedis, }); plazaEvents = createPlazaEventService(pool); plazaRecommend = createPlazaRecommendService(pool, { eventService: plazaEvents, formatPostRow, loadViewerReactions: (viewerId, postIds) => plazaInteractions.loadViewerReactions(viewerId, postIds), algorithmConfig: plazaAlgorithmConfig, }); plazaPosts = createPlazaPostService(pool, { loadViewerReactions: (viewerId, postIds) => plazaInteractions.loadViewerReactions(viewerId, postIds), plazaRedis, algorithmConfig: plazaAlgorithmConfig, recommendService: plazaRecommend, onPostPublished: (postId) => plazaSeo?.notifyPostPublished(postId), loadFeaturedPosts: async (viewerId) => { if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} }; return plazaOps.loadActiveFeaturedPosts(viewerId); }, }); plazaOps = createPlazaOpsService(pool, { formatPostRow, reviewPost: (...args) => plazaPosts.reviewPost(...args), invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(), }); startPlazaTasks({ pool, plazaRedis, recalculateHotScores, writebackPublications, }); mindSpaceCleanup = mindSpaceRuntimeAdapter.cleanupService; await ensurePlanCatalogSchema(pool); const planCatalogService = createPlanCatalogService(pool); subscriptionService = createSubscriptionService(pool, { getPlanAsync: (planType) => planCatalogService.getPlan(planType), }); subscriptionService._planCatalogService = planCatalogService; userAuth = createUserAuth(pool, { usersRoot: USERS_ROOT, h5Root: __dirname, defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), subscriptionService, getMindSearchConfig: () => mindSearchConfigService.getEffectiveConfig(), provisionUserDataSpace: async ({ userId, workspaceRoot }) => { const service = createUserDataSpaceService({ workspaceRoot, userId, query: pool }); return service.ensureReady(); }, }); sessionAccess = createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() }); if (sessionAccess.enabled) { console.log('[Portal] Session Broker enabled (MEMIND_SESSION_BROKER_ENABLED=1)'); } const routerDecisionMode = resolveNormalizedRouterDecisionMode(process.env); const h5SessionFlags = [ sessionAccess.enabled && 'MEMIND_SESSION_BROKER_ENABLED', isSessionBrokerMetricsEnabled() && 'MEMIND_SESSION_BROKER_METRICS', routerDecisionMode !== 'off' && `MEMIND_ROUTER_NORMALIZED_DECISION=${routerDecisionMode}`, isSessionStreamReplayEnabled() && 'MEMIND_SESSION_STREAM_REPLAY', ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_SSE_EVENT_TAXONOMY ?? '').trim().toLowerCase()) && 'MEMIND_SSE_EVENT_TAXONOMY', ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_RUN_STREAM_REPLAY ?? '').trim().toLowerCase()) && 'MEMIND_RUN_STREAM_REPLAY', ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_H5_HTML_FINISH_GUARD ?? '').trim().toLowerCase()) && 'MEMIND_H5_HTML_FINISH_GUARD', ].filter(Boolean); if (h5SessionFlags.length) { console.log(`[Portal] H5 session flags: ${h5SessionFlags.join(', ')}`); } wechatPayClient = createWechatPayClient(loadWechatPayConfig()); wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth }); rechargeService = createRechargeService(pool, { userAuth, wechatPay: wechatPayClient, }); if (wechatPayClient.enabled) { console.log(`WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`); } if (wechatOAuthService.enabled) { console.log('WeChat OAuth login enabled'); } mindSpaceAgentJobs = mindSpaceRuntimeAdapter.agentJobService; // Shared experience store (etat C): retrieval before / recording after each // agent job, so all instances learn from one another. Gated so it can be // disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is // set we use PostgreSQL + pgvector (semantic search) for this workload only; // the MySQL business DB is untouched. Falls back to MySQL keyword store if PG // init fails (e.g. driver missing) so a misconfig never blocks startup. let experienceService = null; if (mindSpaceServerRuntime.experienceEnabled) { if (process.env.EXPERIENCE_PG_URL) { try { const { createPgExperienceService } = await import('./experience-service-pg.mjs'); experienceService = await createPgExperienceService({ connectionString: process.env.EXPERIENCE_PG_URL, }); console.log('Experience store: PostgreSQL + pgvector'); } catch (error) { console.error( 'Experience PG init failed, falling back to MySQL store:', error instanceof Error ? error.message : error, ); experienceService = createExperienceService(pool); } } else { experienceService = createExperienceService(pool); } } mindSpaceAgentRunner = createMindSpaceAgentRunner({ apiTarget: API_TARGET, apiSecret: API_SECRET, userAuth, sessionAccess, agentJobService: mindSpaceAgentJobs, experienceService, }); mindSpaceAudit = createMindSpaceAuditWriter(pool); // Agent job consumer: a DB-polling worker that atomically claims queued jobs // (claimNextJob uses SELECT ... FOR UPDATE SKIP LOCKED, so multiple instances // can run this loop without double-processing) and runs them via the runner. // Opt-in per instance: must NOT run on the 105 stateless front (see // docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED. mindSpaceRuntimeAdapter.startBackgroundJobs({ publicationCleanupIntervalMs: 60 * 1000, agentWorker: mindSpaceServerRuntime.agentWorker, agentRunner: mindSpaceAgentRunner, workspaceMaintenanceEnabled: WORKSPACE_MAINTENANCE_ENABLED, publishRoot: mindSpaceServerRuntime.publishRoot, startWorkspaceThumbnailWatcher, startWorkspaceAssetSyncWatcher, syncUserWorkspaceByDirKey: async (dirKey, options) => { const userId = await resolveUserIdByDirKey(dirKey); if (!userId) return; await mindSpaceAssets.syncWorkspaceAssets(userId, options); }, expireStaleUploadsIntervalMs: 5 * 60 * 1000, }); await userAuth.ensureAdminUser(); const userDataSpaceBackfill = await userAuth.ensureAllUserDataSpaces(); if (userDataSpaceBackfill.errors.length > 0) { console.warn( `[PageData] PG space backfill incomplete: ${userDataSpaceBackfill.errors.length} user(s) failed`, ); } llmProviderService = createLlmProviderService(pool, { apiTarget: API_TARGET, apiTargets: API_TARGETS, apiSecret: API_SECRET, }); assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService }); imageMakeAdminConfigService = createImageMakeAdminConfigService(pool, { env: process.env, llmProviderService, }); await imageMakeAdminConfigService.ensureSchema(); let imageMakeClient = null; try { imageMakeClient = createImageMakeClientFromEnv(process.env); } catch (error) { console.warn('[image_make] invalid local configuration; integration stays disabled:', error?.message ?? error); } mindSpaceImageGeneration = createMindSpaceImageGenerationService({ configService: assetGatewayConfigService, assetService: mindSpaceAssets, imageMakeClient, imageReviewService: createMindSpaceImageReviewService({ llmProviderService, env: process.env, logger: console, }), env: process.env, logger: console, }); wordFilterService = createWordFilterService(pool); void llmProviderService .ensureBootstrapRelay() .then((result) => { if (result.created) { console.log(`LLM relay bootstrap created: ${RELAY_BOOTSTRAP.name}`); } }) .catch((err) => { console.warn('LLM relay bootstrap skipped:', err instanceof Error ? err.message : err); }); void llmProviderService.syncSelectedToGoosed().catch((err) => { console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err); }); memoryV2ConfigService = createMemoryV2AdminConfigService(pool); skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root: __dirname }); wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); conversationMemoryService = createConversationMemoryService(pool, { llmProviderService, getEffectiveEnv: async () => { const state = await memoryV2ConfigService.getRuntimeState().catch(() => null); return { ...process.env, ...(state?.overrides ?? {}) }; }, }); memoryV2 = await createManagedMemoryV2Runtime({ legacyMemoryService: conversationMemoryService, configService: memoryV2ConfigService, mysqlPool: pool, }); sessionSnapshotService = createSessionSnapshotService(pool, { conversationMemoryService, memoryV2, }); if (isSessionStreamReplayEnabled()) { sessionStreamStore = createSessionStreamStore({ pool }); } directChatService = createDirectChatService({ userAuth, sessionAccess, llmProviderService, sessionSnapshotService, memoryV2, conversationMemoryService, }); chatIntentRouter = createManagedChatIntentRouter({ llmProviderService, memoryV2, conversationMemoryService, configService: memoryV2ConfigService, }); // GOOSED PROXY BOUNDARY: H5 chat → goosed 唯一入口(Patch 5, goosed-proxy-boundary.mjs) tkmindProxy = createTkmindProxy({ apiTarget: API_TARGET, apiTargets: API_TARGETS, apiSecret: API_SECRET, userAuth, sessionAccess, sessionStreamStore, llmProviderService, subscriptionService, sessionSnapshotService, conversationMemoryService, memoryV2, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId); const buffer = await fs.promises.readFile(assetPath); return { buffer, mimeType: asset.mimeType }; } : null, }); toolGateway = createToolGateway({ llmProviderService }); agentRunGateway = createAgentRunGateway({ pool, userAuth, sessionAccess, tkmindProxy, toolGateway, directChatService, chatIntentRouter, sessionSnapshotService, conversationMemoryService, observePersonalMemoryOnSuccess: async ({ userId, sessionId, userMessage }) => { if (!memoryV2?.observePersonalMemory) return; await memoryV2.observePersonalMemory({ userId, sessionId, messages: [userMessage], }); }, syncUserPagesOnSuccess: async ({ userId, sessionId, runStartedAtMs }) => syncUserGeneratedPages(userId, { sessionId, sinceMs: runStartedAtMs }), isSessionExternallyBusy: ({ sessionId }) => Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) > 0, validateRunDeliverables: async ({ userId, deliverables }) => { const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId }); const pageDataErrors = []; for (const page of deliverables?.pages ?? []) { const relativePath = normalizeWorkspaceRelativePath(page.workspaceRelativePath); if (!relativePath?.startsWith('public/')) continue; const filePath = path.resolve(publishDir, relativePath); if (!filePath.startsWith(`${path.resolve(publishDir)}${path.sep}`) || !fs.existsSync(filePath)) continue; const html = fs.readFileSync(filePath, 'utf8'); const evaluation = evaluatePageDataHtmlContent(html, { relativePath }); if (!evaluation.usesPageDataApi) continue; for (const issue of evaluation.issues) { pageDataErrors.push({ code: issue, message: `${relativePath} Page Data HTML 不可交付:${issue}` }); } const policy = page.pageId ? readPageAccessPolicy(publishDir, page.pageId) : null; for (const [dataset, actions] of detectPageDataDatasetUsageFromHtml(html)) { for (const action of ['read', 'insert']) { if (actions?.[action] && !policyAllowsAction(policy, dataset, action)) { pageDataErrors.push({ code: 'page_data_policy_action_missing', message: `${relativePath} 的 ${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`, }); } } } } const violations = scanWorkspaceFilesForProhibitedBrowserStorage({ publishDir, relativePaths: (deliverables?.pages ?? []) .map((page) => page.workspaceRelativePath) .filter(Boolean), }); return { errors: [...pageDataErrors, ...violations.map((violation) => ({ code: 'browser_storage_forbidden', message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`, }))], }; }, autoDispatch: ['1', 'true', 'yes', 'on'].includes( String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(), ), maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), }); const wechatMp = await loadWechatMpModule(__dirname); wechatMpService = wechatMp.createWechatMpService({ config: WECHAT_MP_CONFIG, userAuth, sessionAccess, pageDataFinishGuard: authPool ? { pool: authPool, h5Root: __dirname, storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, } : null, apiFetch: tkmindProxy.apiFetch, startAgentSession: ({ userId, workingDir, sessionPolicy }) => tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }), sessionApiFetch: async (sessionId, pathname, init) => { const target = await tkmindProxy.resolveTarget(sessionId); return tkmindProxy.apiFetchTo(target, pathname, init); }, submitSessionReply: ({ userId, sessionId, requestId, userMessage }) => tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage), scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, wechatScheduleLlmConfigService, llmProviderService, onPageGenerated: async ({ userId, sessionId, artifacts = [] }) => { for (const artifact of artifacts) { void sendMindSpaceAnalyticsEvent({ config: mindSpaceAnalyticsConfig, eventName: 'page_generated', ownerId: userId, ownerSegment: resolveAnalyticsOwnerSegment(await userAuth?.getUserById(userId).catch(() => null) ?? {}), ownerLabel: resolveAnalyticsOwnerLabel(await userAuth?.getUserById(userId).catch(() => null) ?? {}), pageId: artifact.relativePath, publicationId: sessionId, agentRunId: sessionId, channel: 'wechat_mp', url: artifact.url || artifact.relativePath || '/', }); } }, applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId), refreshSessionSnapshot: sessionSnapshotService?.isEnabled() ? (sessionId, userId) => sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => { const target = await tkmindProxy.resolveTarget(sessionId); return tkmindProxy.apiFetchTo(target, pathname, init); }) : null, }); notificationDispatcher = createNotificationDispatcher({ sendWechatTextToUser: wechatMpService?.enabled ? (userId, text) => wechatMpService.sendTextToUser(userId, text) : null, }); userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => { await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey }); }); if ( process.env.H5_REMINDER_WORKER_ENABLED === '1' && wechatMpService?.enabled && scheduleService ) { scheduleReminderWorker = startScheduleReminderWorker({ scheduleService, notificationDispatcher, }); console.log('Schedule reminder worker enabled'); } if (subscriptionService) { const subExpiryTimer = setInterval(async () => { try { const { renewed, failed } = await subscriptionService.processAutoRenewals(); if (renewed > 0) console.log(`Auto-renewed ${renewed} subscription(s)`); if (failed > 0) console.log(`Auto-renew failed for ${failed} subscription(s) (balance insufficient)`); const n = await subscriptionService.expireStaleSubscriptions(); if (n > 0) console.log(`Expired ${n} stale subscription(s)`); } catch (err) { console.warn('Subscription expiry check failed:', err); } }, 60 * 60 * 1000); // hourly subExpiryTimer.unref?.(); } mindSpacePageEditSession = createPageEditSessionService({ apiTarget: API_TARGET, apiSecret: API_SECRET, userAuth, sessionAccess, pageService: mindSpacePages, pageLiveEdit: mindSpacePageLiveEdit, llmProviderService, }); if (wechatMpService?.enabled) { console.log('WeChat MP webhook enabled'); } console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`); return true; } catch (err) { console.error('User auth bootstrap failed:', err); if (isDatabaseConfigured() && process.env.NODE_ENV === 'production') { console.error( 'Fatal: database is configured but user auth bootstrap failed; exiting so launchd can retry', ); process.exit(1); } return false; } } const userAuthReady = bootstrapUserAuth(); function legacySessionToken(req) { return parseCookies(req.get('cookie'))[AUTH_COOKIE]; } function userToken(req) { return parseCookies(req.get('cookie'))[USER_COOKIE]; } function setUserLoginCookies(res, req, token) { res.set( 'Set-Cookie', userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)), ); } function clearUserLoginCookies(res, req) { const secure = isSecureRequest(req); const domain = resolveCookieDomainForRequest(req); const cookies = clearUserLogoutCookies(secure, domain); if (legacyAuth) { cookies.push(clearSessionCookie(secure)); } res.set('Set-Cookie', cookies); } async function attachUserSession(req, _res, next) { if (!userAuth) return next(); const token = userToken(req); req.userToken = token; try { req.userSession = token ? await userAuth.verify(token) : null; req.userSessionError = null; } catch (err) { req.userSession = null; req.userSessionError = err; console.error('[Auth] session verify failed:', err instanceof Error ? err.message : err); } next(); } app.use(attachUserSession); // ============ Legacy password auth ============ async function resolveSkillRuntimeForClient() { if (!skillRuntimeConfigService?.getPublicRuntimeConfig) return null; try { return await skillRuntimeConfigService.getPublicRuntimeConfig(); } catch (err) { console.warn('[SkillRuntime] public config unavailable:', err instanceof Error ? err.message : err); return null; } } app.get('/auth/status', async (req, res) => { await userAuthReady; if (userAuth) { try { const me = await userAuth.getMe(userToken(req)); if (!me) return res.json({ authenticated: false, mode: 'user' }); const row = await userAuth.getUserById(me.id); const capabilityState = await userAuth.resolveUserCapabilities(row); const skillRuntime = await resolveSkillRuntimeForClient(); return res.json({ authenticated: true, user: me, mode: 'user', capabilities: capabilityState.capabilities, grantedSkills: capabilityState.grantedSkills ?? [], unrestricted: capabilityState.unrestricted, skillRuntime, }); } catch (err) { console.error('[Auth] status failed:', err instanceof Error ? err.message : err); return res.status(503).json({ authenticated: false, mode: 'unavailable', message: '用户认证服务不可用,请稍后重试', }); } } if (isDatabaseConfigured()) { return res.status(503).json({ authenticated: false, mode: 'unavailable', message: '用户认证服务不可用,请稍后重试', }); } if (legacyAuth) { return res.json({ authenticated: legacyAuth.verify(legacySessionToken(req)), mode: 'legacy', }); } return res.json({ authenticated: false, mode: 'none' }); }); app.post('/auth/login', jsonBody, async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (userAuth) { const { username, password } = req.body ?? {}; if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = await userAuth.login({ username, password, ip: req.ip }); if (!result.ok) { if (result.retryAfterMs > 0) { res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); return res.status(429).json({ message: result.message }); } return res.status(401).json({ message: result.message }); } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, mode: 'user', sessionToken: result.token, }); } if (!legacyAuth) { return res.status(503).json({ message: '未配置用户数据库或访问密码' }); } const password = typeof req.body?.password === 'string' ? req.body.password : ''; const result = legacyAuth.login(password, req.ip); if (!result.ok) { if (result.retryAfterMs > 0) { res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); return res.status(429).json({ message: '尝试次数过多,请稍后再试' }); } return res.status(401).json({ message: '密码错误,请重试' }); } res.set('Set-Cookie', sessionCookie(result.token, secure)); return res.json({ authenticated: true, mode: 'legacy' }); }); app.post('/auth/wechat-miniapp/login', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth) { return res.status(503).json({ message: '未启用用户系统' }); } const miniappConfig = loadWechatMiniappConfig(); if (!miniappConfig.enabled) { return res.status(503).json({ message: '小程序登录未配置,请联系管理员' }); } const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''; if (!code) { return res.status(400).json({ message: '缺少微信登录 code' }); } try { const session = await exchangeMiniProgramCode({ appId: miniappConfig.appId, appSecret: miniappConfig.appSecret, code, }); const result = await userAuth.loginByWechatMiniProgram({ appId: miniappConfig.appId, openid: session.openid, unionid: session.unionid, }); if (!result.ok) { return res.status(401).json({ message: result.message || '微信登录失败' }); } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, mode: 'user', isNewUser: Boolean(result.isNewUser), sessionToken: result.token, }); } catch (err) { const message = err instanceof Error ? err.message : '微信登录失败'; const status = err?.code === 'wechat_miniapp_code_failed' ? 401 : 400; return res.status(status).json({ message }); } }); app.post('/auth/register', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth) { return res.status(503).json({ message: '未启用用户注册' }); } const { username, password, displayName, email } = req.body ?? {}; const result = await userAuth.register({ username, password, displayName, email }); if (!result.ok) { const status = result.message.includes('已存在') ? 409 : 400; return res.status(status).json({ message: result.message }); } if (plazaSeo && req.body?.utm_source) { void plazaSeo .recordAttribution( { event_type: 'signup', utm_source: req.body.utm_source, utm_medium: req.body.utm_medium, utm_campaign: req.body.utm_campaign, ref_id: req.body.ref ?? req.body.ref_id, user_id: result.user?.id ?? null, }, plazaClientIp(req), ) .catch(() => {}); } return res.json({ ok: true, user: result.user }); }); app.post('/auth/reset-password', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth) { return res.status(503).json({ message: '未启用用户系统' }); } const { username, email, password } = req.body ?? {}; const result = await userAuth.resetPassword({ username, email, password }); if (!result.ok) { return res.status(400).json({ message: result.message }); } return res.json({ ok: true }); }); app.get('/auth/wechat/config', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.json({ enabled: false, inWechat: isWechatUserAgent(req.get('user-agent') || ''), scanEnabled: false, }); } return res.json(wechatOAuthService.publicConfig(req)); }); app.get('/auth/wechat/js-sdk-signature', async (req, res) => { await userAuthReady; if (!userAuth || !wechatMpService?.enabled) { return res.status(503).json({ message: '微信 JS-SDK 未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const pageUrl = String(req.query?.url ?? '').split('#')[0]; if (!pageUrl) { return res.status(400).json({ message: '缺少 url' }); } try { const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, requestHost: req.get('x-forwarded-host') || req.get('host') || '', }); const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); return res.json(payload); } catch (err) { const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; console.warn('WeChat JS-SDK signature failed:', message); return res.status(502).json({ message }); } }); app.get('/auth/wechat/public-js-sdk-signature', async (req, res) => { await userAuthReady; if (!wechatMpService?.enabled) { return res.status(503).json({ message: '微信 JS-SDK 未启用' }); } const pageUrl = String(req.query?.url ?? '').split('#')[0]; if (!pageUrl) { return res.status(400).json({ message: '缺少 url' }); } try { const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, requestHost: req.get('x-forwarded-host') || req.get('host') || '', }); const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); return res.json(payload); } catch (err) { const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; console.warn('WeChat public JS-SDK signature failed:', message); return res.status(502).json({ message }); } }); app.get('/auth/wechat/status', async (req, res) => { await userAuthReady; if (!userAuth || !wechatOAuthService?.enabled) { return res.json({ enabled: false, bound: false }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const config = loadWechatOAuthConfig(); const status = await userAuth.getWechatBindingStatus(me.id, config.appId); return res.json({ enabled: true, ...status }); }); if (WECHAT_MP_CONFIG.enabled) { app.get('/auth/wechat/agent-route', async (req, res) => { await userAuthReady; if (!userAuth || !wechatMpService?.enabled) { return res.status(503).json({ message: '公众号 Agent 未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); return res.json(await wechatMpService.getRouteStatusForUser(me.id)); }); app.post('/auth/wechat/agent-route/reset', async (req, res) => { await userAuthReady; if (!userAuth || !wechatMpService?.enabled) { return res.status(503).json({ message: '公众号 Agent 未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); try { const result = await wechatMpService.recreateRouteForUser(me.id); if (!result.ok) return res.status(400).json({ message: result.message }); return res.json(result); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '重建公众号 Agent 路由失败', }); } }); } app.get('/auth/wechat/pending/:token', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const pending = await userAuth.getWechatPendingBind(req.params.token); if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' }); return res.json({ nickname: pending.nickname, avatarUrl: pending.avatar_url, returnTo: pending.return_to || '/', }); }); app.post('/auth/wechat/register', jsonBody, async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); const result = await userAuth.completeWechatRegister({ pendingToken }); if (!result.ok) return res.status(400).json({ message: result.message }); if (result.isNewUser && plazaSeo) { void plazaSeo .recordAttribution( { event_type: 'signup', utm_source: result.utmSource || 'wechat', utm_medium: result.utmMedium, utm_campaign: result.utmCampaign, user_id: result.user?.id ?? null, }, plazaClientIp(req), ) .catch(() => {}); } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, returnTo: result.returnTo || '/', }); }); app.post('/auth/wechat/bind', jsonBody, async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; const username = typeof req.body?.username === 'string' ? req.body.username : ''; const password = typeof req.body?.password === 'string' ? req.body.password : ''; if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = await userAuth.completeWechatBindAccount({ pendingToken, username, password, ip: req.ip, }); if (!result.ok) { const status = result.retryAfterMs > 0 ? 429 : 401; if (result.retryAfterMs > 0) { res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); } return res.status(status).json({ message: result.message }); } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, bound: true, returnTo: result.returnTo || '/', }); }); app.post('/auth/wechat/scan/start', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.status(503).json({ message: '微信登录未启用' }); } try { const payload = await wechatOAuthService.startScanLogin(req); return res.json(payload); } catch (err) { return res.status(503).json({ message: err instanceof Error ? err.message : '微信扫码登录不可用', }); } }); app.get('/auth/wechat/scan/poll', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.status(503).json({ message: '微信登录未启用' }); } const state = typeof req.query?.state === 'string' ? req.query.state : ''; if (!state) return res.status(400).json({ message: '缺少扫码状态' }); const result = await wechatOAuthService.pollScanLogin(state); if (result.status === 'complete' && result.token) { setUserLoginCookies(res, req, result.token); } return res.json(result); }); app.get('/auth/wechat/authorize', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.status(503).json({ message: '微信登录未启用' }); } try { let bindUserId = null; const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login'; if (intent === 'bind' && userAuth) { const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' }); bindUserId = me.id; } const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId }); return res.redirect(302, redirectUrl); } catch (err) { console.error('WeChat authorize failed:', err); return res.status(500).json({ message: err instanceof Error ? err.message : '微信授权失败' }); } }); app.get('/auth/wechat/callback', async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (!wechatOAuthService?.enabled || !userAuth) { return res.redirect(302, '/?wechat_error=unavailable'); } try { const result = await wechatOAuthService.handleCallback({ code: typeof req.query?.code === 'string' ? req.query.code : '', state: typeof req.query?.state === 'string' ? req.query.state : '', ip: req.ip, }); if (result.action === 'binding_gate') { const params = new URLSearchParams(); params.set('wechat_pending', result.pendingToken); if (result.returnTo && result.returnTo !== '/') { params.set('return_to', result.returnTo); } return res.redirect(302, `/?${params.toString()}`); } if (result.action === 'poll_error') { return res.redirect( 302, `/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`, ); } if (result.isNewUser && plazaSeo) { void plazaSeo .recordAttribution( { event_type: 'signup', utm_source: result.utmSource || 'wechat', utm_medium: result.utmMedium, utm_campaign: result.utmCampaign, user_id: result.user?.id ?? null, }, plazaClientIp(req), ) .catch(() => {}); } if (result.authMode === 'open' || result.authMode === 'scan') { return res.send(`微信登录

扫码登录成功,请返回电脑继续操作。

`); } setUserLoginCookies(res, req, result.token); return res.redirect(302, result.returnTo || '/'); } catch (err) { console.error('WeChat callback failed:', err); const message = encodeURIComponent(err instanceof Error ? err.message : '微信登录失败'); return res.redirect(302, `/?wechat_error=${message}`); } }); app.get('/auth/me', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const [paths, capabilityState, subscription, skillRuntime] = await Promise.all([ userAuth.listPathGrants(me.id), userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)), subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, resolveSkillRuntimeForClient(), ]); return res.json({ user: { ...me, subscription }, paths, capabilities: capabilityState.capabilities, grantedSkills: capabilityState.grantedSkills ?? [], unrestricted: capabilityState.unrestricted, skillRuntime, }); }); app.post('/auth/logout', async (req, res) => { await userAuthReady; if (userAuth) { await userAuth.revoke(userToken(req)); } if (legacyAuth) { legacyAuth.revoke(legacySessionToken(req)); } if (userAuth || legacyAuth) { clearUserLoginCookies(res, req); } res.status(204).end(); }); app.get('/auth/usage', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 }); res.json({ records }); }); app.post('/auth/feedback', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { return res.status(503).json({ message: '反馈服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); try { const result = await feedbackService.submit(me.id, { type: req.body?.type, title: req.body?.title, description: req.body?.description, contact: req.body?.contact, images: req.body?.images, context: req.body?.context, }); res.status(201).json({ feedback: result }); } catch (err) { const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; if (code === 'invalid_input') { return res.status(400).json({ message: err instanceof Error ? err.message : '提交内容无效' }); } console.warn('Submit feedback failed:', err instanceof Error ? err.message : err); return res.status(500).json({ message: '反馈提交失败,请稍后重试' }); } }); app.get('/auth/feedback', async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { return res.status(503).json({ message: '反馈服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50); const items = await feedbackService.listForUser(me.id, { limit }); res.json({ items }); }); app.get('/auth/feedback/board', async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { return res.status(503).json({ message: '反馈服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const page = Math.max(Number(req.query?.page) || 1, 1); const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10); try { const result = await feedbackService.listAll({ page, limit }); res.json(result); } catch (err) { console.warn('List feedback board failed:', err instanceof Error ? err.message : err); return res.status(500).json({ message: '反馈列表加载失败' }); } }); app.get('/auth/feedback/:feedbackId', async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { return res.status(503).json({ message: '反馈服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); try { const item = await feedbackService.getById(req.params.feedbackId, me.id); res.json({ item, isMine: item.userId === me.id }); } catch (err) { const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; if (code === 'feedback_not_found') { return res.status(404).json({ message: err instanceof Error ? err.message : '反馈不存在' }); } console.warn('Get feedback detail failed:', err instanceof Error ? err.message : err); return res.status(500).json({ message: '反馈详情加载失败' }); } }); app.get('/auth/notifications', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const rawStatus = typeof req.query?.status === 'string' ? req.query.status : 'unread'; const status = ['all', 'unread', 'read'].includes(rawStatus) ? rawStatus : 'all'; const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 100); try { const notifications = await scheduleService.listUserNotifications({ userId: me.id, status, limit }); res.json({ notifications }); } catch (err) { console.warn('List user notifications failed:', err instanceof Error ? err.message : err); res.status(500).json({ message: '通知列表加载失败' }); } }); app.get('/auth/notifications/events', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); res.status(200); res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); let closed = false; let lastNotificationId = null; const sendEvent = (event, data) => { if (closed || res.destroyed) return; res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`); }; const checkUnread = async () => { if (closed) return; try { const notifications = await scheduleService.listUserNotifications({ userId: me.id, status: 'unread', limit: 1, }); const latest = notifications[0] ?? null; const nextId = latest?.id ?? null; if (nextId && nextId !== lastNotificationId) { lastNotificationId = nextId; sendEvent('notification', { notification: latest }); } else if (!nextId) { lastNotificationId = null; } } catch { sendEvent('sync', { reason: 'check_failed' }); } }; sendEvent('ready', { ok: true }); await checkUnread(); const checkTimer = setInterval(() => { void checkUnread(); }, 2500); const keepaliveTimer = setInterval(() => { sendEvent('ping', { at: Date.now() }); }, 25000); req.on('close', () => { closed = true; clearInterval(checkTimer); clearInterval(keepaliveTimer); }); }); app.post('/auth/notifications/:id/read', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const ok = await scheduleService.markUserNotificationRead({ userId: me.id, notificationId: req.params.id, }); if (!ok) return res.status(404).json({ message: '通知不存在或已读' }); res.json({ ok: true }); }); app.post('/auth/notifications/read-all', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const updated = await scheduleService.markAllUserNotificationsRead({ userId: me.id }); res.json({ ok: true, updated }); }); app.delete('/auth/notifications/:id', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const ok = await scheduleService.deleteUserNotification({ userId: me.id, notificationId: req.params.id, }); if (!ok) return res.status(404).json({ message: '通知不存在' }); res.status(204).end(); }); app.delete('/auth/notifications', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const status = typeof req.query?.status === 'string' ? req.query.status : 'all'; const deleted = await scheduleService.clearUserNotifications({ userId: me.id, status }); res.json({ ok: true, deleted }); }); app.get('/auth/billing/ledger', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const limit = Math.min(Math.max(Number(req.query?.limit) || 30, 1), 100); const entries = await userAuth.listBillingLedger({ userId: me.id, limit, types: ['recharge', 'adjust', 'refund'], }); res.json({ entries }); }); app.get('/auth/billing/config', async (req, res) => { await userAuthReady; if (!userAuth || !rechargeService) { return res.status(503).json({ message: '未启用计费系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const [config, sub] = await Promise.all([ rechargeService.getBillingConfig(me.id), subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, ]); return res.json({ ...config, subscription: sub }); }); app.get('/auth/billing/subscription', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const sub = subscriptionService ? await subscriptionService.getActiveSubscription(me.id) : null; const planCatalog = subscriptionService?._planCatalogService; const plans = (!sub && planCatalog) ? await planCatalog.listPlans({ includeInactive: false }) : sub ? undefined : PLAN_CATALOG; return res.json({ subscription: sub, plans }); }); app.get('/auth/billing/plans', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const planCatalog = subscriptionService?._planCatalogService; const plans = planCatalog ? (await planCatalog.listPlans({ includeInactive: false })) .filter((p) => p.priceCents > 0) .map((p) => ({ key: p.planType, ...p })) : Object.entries(PLAN_CATALOG) .filter(([, plan]) => plan.priceCents > 0) .map(([key, plan]) => ({ key, ...plan })); const [sub, wallet] = await Promise.all([ subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, userAuth.getUserById(me.id), ]); return res.json({ plans, subscription: sub, balanceCents: wallet ? Number(wallet.balance_cents ?? 0) : 0, }); }); app.post('/auth/billing/subscribe', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !subscriptionService) { return res.status(503).json({ message: '未启用订阅系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); if (me.status === 'disabled') return res.status(403).json({ message: '账户已禁用' }); const { planType, autoRenew = false } = req.body ?? {}; if (!planType || typeof planType !== 'string') { return res.status(400).json({ message: '请选择套餐' }); } const result = await subscriptionService.purchaseSubscription(me.id, planType, Boolean(autoRenew)); if (!result.ok) { if (result.code === 'INSUFFICIENT_BALANCE') { return res.status(402).json({ message: result.message, code: result.code, balanceCents: result.balanceCents, requiredCents: result.requiredCents, shortfallCents: result.shortfallCents, }); } if (result.code === 'DOWNGRADE_NOT_ALLOWED') { return res.status(409).json({ message: result.message, code: result.code, currentPlanType: result.currentPlanType, }); } return res.status(400).json({ message: result.message }); } return res.json({ subscription: result.subscription, balanceCents: result.balanceCents }); }); app.post('/auth/billing/space-purchase', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !mindSpace) { return res.status(503).json({ message: '未启用空间系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const sizeMb = Number(req.body?.sizeMb); const result = await userAuth.purchaseSpaceQuota(me.id, sizeMb); if (!result.ok) { if (result.code === 'INSUFFICIENT_BALANCE') { return res.status(402).json({ message: result.message, code: result.code, details: { code: 'INSUFFICIENT_BALANCE', balanceCents: result.balanceCents, minRechargeCents: result.minRechargeCents, suggestedTiers: result.suggestedTiers, }, }); } return res.status(400).json({ message: result.message }); } const quota = await mindSpace.getQuota(me.id); return res.json({ quota: quota ?? result.quota, balanceCents: result.balanceCents, purchasedMb: sizeMb, costCents: sizeMb * 200, }); }); app.post('/auth/billing/auto-renew', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !subscriptionService) { return res.status(503).json({ message: '未启用订阅系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const { enabled } = req.body ?? {}; if (typeof enabled !== 'boolean') { return res.status(400).json({ message: '请传入 enabled: true/false' }); } const result = await subscriptionService.setAutoRenew(me.id, enabled); return res.json(result); }); app.post('/auth/billing/recharge-orders', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !rechargeService) { return res.status(503).json({ message: '未启用计费系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const amountCents = Number(req.body?.amountCents); const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene) ? req.body.payScene : 'native'; const result = await rechargeService.createOrder({ userId: me.id, amountCents, payScene, clientIp: req.ip, }); if (!result.ok) return res.status(400).json({ message: result.message }); return res.status(201).json({ order: result.order }); }); app.get('/auth/billing/recharge-orders/:orderId', async (req, res) => { await userAuthReady; if (!userAuth || !rechargeService) { return res.status(503).json({ message: '未启用计费系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const order = await rechargeService.getOrderForUser(me.id, req.params.orderId); if (!order) return res.status(404).json({ message: '订单不存在' }); let balanceCents = null; if (order.status === 'paid') { const user = await userAuth.getUserById(me.id); balanceCents = user ? Number(user.balance_cents ?? 0) : null; } return res.json({ order, balanceCents }); }); const wechatNotifyBody = express.raw({ type: ['application/json', 'text/xml', 'application/xml'], limit: '64kb', }); const wechatMpBody = express.text({ type: ['text/xml', 'application/xml'], limit: '128kb', }); app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => { await userAuthReady; const isV2 = wechatPayClient?.apiVersion === 'v2'; if (!rechargeService || !wechatPayClient?.enabled) { if (isV2) { return res .status(503) .type('text/xml') .send( '', ); } return res.status(503).json({ code: 'FAIL', message: '支付未启用' }); } try { const bodyText = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body ?? ''); await rechargeService.handleWechatNotify({ headers: req.headers, body: bodyText }); if (isV2) { return res.type('text/xml').send(WECHAT_NOTIFY_SUCCESS_V2); } return res.json({ code: 'SUCCESS', message: '成功' }); } catch (err) { console.error('WeChat notify failed:', err); const message = err instanceof Error ? err.message : '处理失败'; if (isV2) { return res .status(500) .type('text/xml') .send( ``, ); } return res.status(500).json({ code: 'FAIL', message }); } }); if (WECHAT_MP_CONFIG.enabled) { app.get('/webhooks/wechat-mp/messages', async (req, res) => { await userAuthReady; if (!wechatMpService?.enabled) { return res.status(503).send('wechat mp disabled'); } const result = wechatMpService.verifyUrlChallenge(req.query); if (!result.ok) { console.warn('WeChat MP verify failed:', { encryptType: req.query.encrypt_type ?? null, timestamp: req.query.timestamp ?? null, nonce: req.query.nonce ?? null, }); return res.status(result.status ?? 403).send(result.body ?? 'invalid signature'); } console.log('WeChat MP verify ok:', { encryptType: req.query.encrypt_type ?? null, timestamp: req.query.timestamp ?? null, nonce: req.query.nonce ?? null, }); return res.type('text/plain').send(String(result.body ?? '')); }); app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => { await userAuthReady; if (!wechatMpService?.enabled) { return res.status(503).send('wechat mp disabled'); } try { const bodyText = String(req.body ?? ''); const fromUser = bodyText.match(/<\/FromUserName>/)?.[1] ?? null; const msgType = bodyText.match(/<\/MsgType>/)?.[1] ?? null; const content = bodyText.match(/<\/Content>/)?.[1] ?? null; console.log('WeChat MP message received:', { at: new Date().toISOString(), fromUser: fromUser ? `${fromUser.slice(0, 8)}...` : null, msgType, contentPreview: content ? `${String(content).slice(0, 24)}` : null, }); const result = await wechatMpService.handleInboundMessage(req.body, req.query); if (result.task) void result.task; if (result.contentType) res.type(result.contentType); return res.status(result.status ?? 200).send(result.body ?? 'success'); } catch (err) { console.error('WeChat MP message failed:', err); return res.status(500).send('internal error'); } }); } // ============ Wiki API ============ function wikiCookie(token, secure) { const parts = [ `${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=604800', ]; if (secure) parts.push('Secure'); return parts.join('; '); } function clearWikiCookie(secure) { const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; if (secure) parts.push('Secure'); return parts.join('; '); } const wikiApi = express.Router(); wikiApi.use(jsonBody); wikiApi.post('/auth/register', (req, res) => { const { username, password, displayName } = req.body || {}; if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = wikiAuth.register(username, password, displayName); if (!result.ok) return res.status(409).json({ message: result.message }); return res.json({ ok: true, user: result.user }); }); wikiApi.post('/auth/login', (req, res) => { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = wikiAuth.login(username, password); if (!result.ok) return res.status(401).json({ message: result.message }); const secure = isSecureRequest(req); res.set('Set-Cookie', wikiCookie(result.token, secure)); return res.json({ ok: true, user: result.user }); }); wikiApi.post('/auth/logout', (req, res) => { const cookies = parseCookies(req.get('cookie')); wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]); res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req))); return res.json({ ok: true }); }); wikiApi.get('/auth/me', (req, res) => { const cookies = parseCookies(req.get('cookie')); const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]); if (!session) return res.json({ authenticated: false, user: null }); const user = wikiAuth.getUser(session.username); return res.json({ authenticated: true, user }); }); function requireWikiAuth(req, res, next) { const cookies = parseCookies(req.get('cookie')); const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]); if (!session) return res.status(401).json({ message: '未登录' }); req.wikiUser = session; next(); } wikiApi.get('/pages', requireWikiAuth, (req, res) => { const { q } = req.query; if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q)); res.json(wikiAuth.listPages(req.wikiUser.username)); }); wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => { const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug); if (!page) return res.status(404).json({ message: '页面不存在' }); res.json(page); }); wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => { const { title, content, tags } = req.body || {}; const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags); res.json(page); }); wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => { wikiAuth.deletePage(req.wikiUser.username, req.params.slug); res.json({ ok: true }); }); app.use('/wiki-api', wikiApi); // ============ TKMind API proxy ============ const api = express.Router(); api.use(jsonUnlessMultipart); attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname }); api.use(async (req, res, next) => { await userAuthReady; if (req.path === '/status' || req.path === '/runtime/status') return next(); if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); if (req.path === '/agent/mindspace_asset_delete') return next(); if (req.path === '/agent/mindspace_asset_download') return next(); if (req.path === '/agent/mindspace_image_generate') return next(); if (req.path === '/internal/image-make/runtime-config') return next(); if (req.path === '/config/blocked-words') return next(); if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { return next(); } const plazaPublic = isPlazaPublicRead(req.path, req.method); const pageDataPublic = isPageDataPublicPath(req.path, req.method); // Let the retired namespace reach its explicit 410 handler below. Without // this exception the outer auth middleware turns an old public HTML request // into a misleading 401/403 before the legacy-endpoint block can run. const legacyPageDataApi = isLegacyPageDataApiPath(req.path); if (userAuth && tkmindProxy) { if (req.userSessionError) { if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); } try { if (req.userSession) { const me = await userAuth.getMe(req.userToken); if (me) req.currentUser = me; } if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); if (!req.userSession) { return res.status(401).json({ message: '未授权,请重新登录' }); } const me = await userAuth.getMe(req.userToken); if (!me) return res.status(401).json({ message: '登录已过期' }); req.currentUser = me; return next(); } catch (err) { console.error('[Auth] API auth failed:', err instanceof Error ? err.message : err); return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); } } if (legacyAuth?.verify(legacySessionToken(req))) return next(); return res.status(401).json({ message: '未授权,请重新登录' }); }); api.get('/internal/image-make/runtime-config', async (req, res) => { if (!imageMakeAdminConfigService?.authorizeRuntimeRequest) { return res.status(503).json({ message: 'image_make 配置服务未启用' }); } const authHeader = String(req.get('authorization') ?? ''); const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; if (!imageMakeAdminConfigService.authorizeRuntimeRequest(token)) { return res.status(401).json({ message: '未授权' }); } const runtime = await imageMakeAdminConfigService.getRuntimeConfig(); if (!runtime.ok) { return res.status(503).json({ message: runtime.message ?? 'image_make 运行时配置无效' }); } return res.json(runtime); }); attachAsrRoutes(api, { sendError, sendData }); attachMindSpaceImageGenerationRoutes(api, { getService: () => mindSpaceImageGeneration, requireInternal: requireInternalAgentSecret, }); attachPageDataRoutes(api, { sendError, sendData, getPageDataService: () => pageDataService, getPageDataPublicService: () => pageDataPublicService, }); api.get('/config/blocked-words', async (_req, res) => { await userAuthReady; if (!wordFilterService) return res.json({ words: [] }); const words = await wordFilterService.listAllForFrontend(); res.json({ words }); }); api.get('/status', async (_req, res, next) => { await userAuthReady; if (userAuth && tkmindProxy) { try { const upstream = await tkmindProxy.apiFetch('/status', { method: 'GET' }); const text = await upstream.text(); return res.status(upstream.status).send(text); } catch (err) { return res.status(502).json({ message: err instanceof Error ? err.message : '代理失败' }); } } return next(); }); function runtimeEnvFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } function runtimeCsvList(value) { return String(value ?? '') .split(',') .map((item) => item.trim()) .filter(Boolean); } function runtimeCodeRunPolicyStatus() { return { enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION), }; } api.get('/runtime/status', async (_req, res) => { await userAuthReady; if (!tkmindProxy?.getRuntimeStatus) { return res.status(503).json({ ok: false, message: 'runtime router unavailable' }); } try { const status = await tkmindProxy.getRuntimeStatus(); const toolQueue = agentRunGateway?.getQueueStatus ? await agentRunGateway.getQueueStatus().catch((err) => ({ error: err instanceof Error ? err.message : String(err), })) : null; if (toolQueue) { status.toolRuntime = { ...(status.toolRuntime ?? {}), codeRunPolicy: runtimeCodeRunPolicyStatus(), queue: toolQueue, }; } return res.json({ ok: true, timestamp: new Date().toISOString(), ...status, }); } catch (err) { return res.status(502).json({ ok: false, message: err instanceof Error ? err.message : 'runtime status failed', }); } }); async function ensureUserMemoryCapability(req, res) { if (!userAuth) { res.status(503).json({ message: '未启用用户系统' }); return null; } const userRow = await userAuth.getUserById(req.currentUser.id); if (!userRow) { res.status(404).json({ message: '用户不存在' }); return null; } const capabilityState = await userAuth.resolveUserCapabilities(userRow); if (!capabilityState.unrestricted && !capabilityState.capabilities.memory_store) { res.status(403).json({ message: '当前账户未开通长期记忆,无法访问该 API' }); return null; } return capabilityState; } async function loadUserVisibleConversation(sessionId, userId) { const target = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo(target, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', }); if (!upstream.ok) { const message = await upstream.text().catch(() => ''); throw new Error(message || '读取会话失败'); } let session = await upstream.json(); if (authPool && userId) { session = await repairSessionConversationFromDb(authPool, session, sessionId, userId); } return filterUserVisibleConversation(session?.conversation ?? []); } async function syncUserMemoriesIntoSession(userId, sessionId) { if (!tkmindProxy || !sessionId) return false; await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId); return true; } async function resolveUserMemoryItems(userId, { sessionId = null, limit = 200 } = {}) { const resolved = await memoryV2.resolve({ userId, sessionId, limit, }); const memories = Array.isArray(resolved?.memories) ? resolved.memories : []; if (memories.length || !conversationMemoryService?.listMemories) { return memories; } return conversationMemoryService.listMemories(userId, { limit }).catch(() => []); } api.post('/user-memory/v1/remember-recent', async (req, res) => { const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); if (!memoryStatus?.enabled) { return res.status(503).json({ message: '长期记忆功能未启用' }); } if (!tkmindProxy) { return res.status(503).json({ message: '会话代理尚未就绪' }); } const capabilityState = await ensureUserMemoryCapability(req, res); if (!capabilityState) return; const sessionId = String(req.body?.sessionId ?? '').trim(); if (!sessionId) { return res.status(400).json({ message: '缺少 sessionId' }); } const owns = await ownsAgentSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } try { const messages = await loadUserVisibleConversation(sessionId, req.currentUser.id); const result = await memoryV2.write({ userId: req.currentUser.id, sessionId, messages, }); const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); const memories = await resolveUserMemoryItems(req.currentUser.id, { sessionId, limit: 200, }); return res.json({ ok: true, analyzed: result.analyzed ?? 0, memories: result.memories ?? 0, totalMemories: memories.length, syncedToSession, }); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '保存长期记忆失败' }); } }); api.post('/user-memory/v1/sync', async (req, res) => { const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); if (!memoryStatus?.enabled) { return res.status(503).json({ message: '长期记忆功能未启用' }); } const capabilityState = await ensureUserMemoryCapability(req, res); if (!capabilityState) return; const sessionId = String(req.body?.sessionId ?? '').trim(); if (!sessionId) { return res.status(400).json({ message: '缺少 sessionId' }); } const owns = await ownsAgentSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } try { const result = await memoryV2.compact({ userId: req.currentUser.id, sessionId, }); const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); const memories = await resolveUserMemoryItems(req.currentUser.id, { sessionId, limit: 200, }); return res.json({ ok: true, analyzed: result.analyzed ?? 0, memories: result.memories ?? 0, totalMemories: memories.length, syncedToSession, }); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '刷新长期记忆失败' }); } }); api.get('/user-memory/v1/items', async (req, res) => { const capabilityState = await ensureUserMemoryCapability(req, res); if (!capabilityState) return; try { const items = await memoryV2.listMemories?.({ userId: req.currentUser.id, status: String(req.query?.status ?? 'active'), limit: req.query?.limit, offset: req.query?.offset, }) ?? []; return res.json({ ok: true, items }); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '读取长期记忆失败' }); } }); api.delete('/user-memory/v1/items/:memoryId', async (req, res) => { const capabilityState = await ensureUserMemoryCapability(req, res); if (!capabilityState) return; try { const result = await memoryV2.forgetMemory?.({ userId: req.currentUser.id, memoryId: req.params.memoryId, }) ?? { ok: false, skipped: true, reason: 'unavailable' }; if (result.skipped) return res.status(409).json(result); return res.json(result); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '删除长期记忆失败' }); } }); api.get('/mindspace/v1/space', async (req, res) => { if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return; const space = await mindSpace.getSpace(req.currentUser.id); if (!space) return sendError(res, req, 404, 'resource_not_found', '用户空间不存在'); return sendData(res, req, space); }); api.post('/mindspace/v1/schedule/reminders/:reminderId/ignore', async (req, res) => { if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); } try { const reminder = await scheduleService.cancelReminder({ userId: req.currentUser.id, reminderId: req.params.reminderId, }); return sendData(res, req, reminder); } catch (err) { const message = err instanceof Error ? err.message : '忽略提醒失败'; return sendError(res, req, 400, 'invalid_schedule_input', message); } }); api.post('/mindspace/v1/schedule/reminders/bulk-delete', async (req, res) => { if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); } try { const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : []; const deleted = await scheduleService.deleteReminders({ userId: req.currentUser.id, reminderIds: ids, }); return sendData(res, req, { deleted }); } catch (err) { const message = err instanceof Error ? err.message : '删除提醒失败'; return sendError(res, req, 400, 'invalid_schedule_input', message); } }); api.get('/mindspace/v1/space/quota', async (req, res) => { if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); const quota = await mindSpace.getQuota(req.currentUser.id); if (!quota) return res.status(404).json({ message: '用户空间不存在' }); return res.json({ data: quota }); }); api.get('/mindspace/v1/space/categories', async (req, res) => { if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); const categories = await mindSpace.listCategories(req.currentUser.id); if (!categories) return res.status(404).json({ message: '用户空间不存在' }); return res.json({ data: categories }); }); api.get('/mindspace/v1/space/cleanup', async (req, res) => { if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; try { const username = req.currentUser.username ?? req.currentUser.slug; const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username); const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0); return sendData(res, req, { items, totalBytes }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/space/cleanup', async (req, res) => { if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; try { const username = req.currentUser.username ?? req.currentUser.slug; const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : []; const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds); const quota = await mindSpace.getQuota(req.currentUser.id); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'space.cleanup', objectType: 'space', objectId: req.currentUser.id, ip: req.ip, detail: { removedCount: result.removedCount, freedBytes: result.freedBytes }, }); return sendData(res, req, { ...result, quota }); } catch (error) { return mindSpaceError(res, req, error); } }); 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) ); } 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(req, res, events) { if (!plazaEvents || !Array.isArray(events) || events.length === 0) return; const sessionId = resolvePlazaSessionId(req, res); void plazaEvents .recordEvents({ userId: req.currentUser?.id ?? null, sessionId, events, }) .catch(() => {}); } function reactionEventType(type) { if (type === 'like' || type === 'collect' || type === 'share') return type; return null; } function ensurePlazaInteractions(res, req) { if (!plazaInteractions) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; } return true; } function resolvePlazaPostUrlForRequest(postId, req) { const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(':')[0]; const base = resolvePlazaPublicBase({ configuredBase: process.env.PLAZA_PUBLIC_BASE ?? '', dev: process.env.NODE_ENV !== 'production', hostname: host, }); return resolvePlazaPostPath(base, postId); } function plazaRouteError(res, req, error) { 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); } function ensurePlazaEnabled(res, req) { if (!plazaPosts) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; } return true; } 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; } function mindSpaceError(res, req, error) { const statusByCode = { invalid_filename: 400, invalid_file_size: 400, category_not_uploadable: 400, file_size_mismatch: 409, invalid_upload_state: 409, file_too_large: 413, unsupported_file_type: 415, category_not_found: 404, upload_not_found: 404, asset_not_found: 404, asset_in_use: 409, page_not_found: 404, upload_expired: 410, quota_exceeded: 429, public_page_limit_exceeded: 429, space_unavailable: 423, invalid_page_input: 400, page_content_too_large: 413, source_message_not_found: 404, invalid_source_message: 409, version_conflict: 409, slug_conflict: 409, page_already_online: 409, invalid_state_transition: 409, invalid_publish_input: 400, publication_not_found: 404, security_scan_required: 422, security_ack_required: 422, security_risk_blocked: 422, invalid_agent_job_input: 400, invalid_agent_job_output: 400, agent_job_not_found: 404, agent_job_token_invalid: 401, agent_job_expired: 410, feature_disabled: 503, cover_ai_unavailable: 503, llm_not_configured: 503, cover_ai_failed: 502, cover_ai_invalid_output: 422, thumbnail_not_supported: 422, thumbnail_image_required: 400, thumbnail_image_invalid: 400, category_not_pageable: 400, redaction_not_needed: 409, invalid_category_code: 400, invalid_page_path: 400, empty_page_content: 400, static_page_not_found: 404, preview_not_supported: 422, }; const code = error?.code ?? 'internal_error'; const status = statusByCode[code] ?? 500; const message = status === 500 && (!error?.code || code === 'internal_error') ? 'MindSpace 服务异常' : error?.message || 'MindSpace 服务异常'; return sendError(res, req, status, code, message, error?.details); } function isRequestBodyTooLarge(error) { return ( error?.type === 'entity.too.large' || error?.status === 413 || error?.statusCode === 413 ); } function apiRequestBodyError(error, req, res, next) { if (!isRequestBodyTooLarge(error)) return next(error); const isMindSpaceUpload = req.path.startsWith('/mindspace/v1/uploads/'); return sendError( res, req, 413, isMindSpaceUpload ? 'file_too_large' : 'request_body_too_large', isMindSpaceUpload ? '上传文件超过单文件大小限制,请压缩后重试' : '请求内容过大,请缩小后重试', ); } function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) { try { assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined); return true; } catch (error) { mindSpaceError(res, req, error); return false; } } function bearerToken(req) { const header = req.get('authorization') ?? ''; const [scheme, value] = header.split(/\s+/, 2); if (scheme?.toLowerCase() !== 'bearer' || !value) return null; return value.trim(); } function requireInternalAgentSecret(req, res) { if (bearerToken(req) === INTERNAL_AGENT_SECRET) return true; sendError(res, req, 401, 'agent_job_token_invalid', '内部 Agent 凭据无效'); return false; } api.post('/mindspace/v1/agent/jobs', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { const job = await mindSpaceAgentJobs.createJob(req.currentUser.id, { jobType: req.body?.job_type, instruction: req.body?.instruction, allowedAssetIds: req.body?.allowed_asset_ids, outputCategoryId: req.body?.output_category_id, outputType: req.body?.output_type, idempotencyKey: req.body?.idempotency_key, locale: req.body?.locale, timezone: req.body?.timezone, capabilities: req.body?.capabilities, }); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'agent_access', objectType: 'agent_job', objectId: job.id, ip: req.ip, detail: { jobType: job.jobType, assetIds: job.assets.map((asset) => asset.assetId), }, }); return sendData(res, req, job, 201); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { return sendData(res, req, await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId)); } catch (error) { return mindSpaceError(res, req, error); } }); // Server-Sent Events stream of a job's progress, so long-running agent tasks can // be dispatched async (enqueue → 202 → subscribe here) instead of holding a // synchronous streaming connection. Polls the job (ownership enforced by getJob) // and pushes on change; closes on terminal status or client disconnect. api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timed_out']); let job; try { job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); } catch (error) { return mindSpaceError(res, req, error); } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); const send = (event, payload) => { res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(payload)}\n\n`); }; let lastSignature = ''; const emitIfChanged = (current) => { const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`; if (signature !== lastSignature) { lastSignature = signature; send('progress', current); } return signature; }; emitIfChanged(job); if (TERMINAL.has(job.status)) { send('done', job); return res.end(); } let closed = false; const cleanup = () => { if (closed) return; closed = true; clearInterval(pollTimer); clearInterval(keepAliveTimer); }; const pollTimer = setInterval(async () => { if (closed) return; try { const current = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); emitIfChanged(current); if (TERMINAL.has(current.status)) { send('done', current); cleanup(); res.end(); } } catch { // Job vanished or transient read error: end the stream rather than leak it. cleanup(); res.end(); } }, mindSpaceServerRuntime.agentWorker.ssePollMs); // Comment line keeps proxies from closing an idle connection. const keepAliveTimer = setInterval(() => { if (!closed) res.write(': keep-alive\n\n'); }, 15_000); pollTimer.unref?.(); keepAliveTimer.unref?.(); req.on('close', cleanup); }); api.get('/mindspace/v1/agent/jobs', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { const result = await mindSpaceAgentJobs.listJobs(req.currentUser.id, { limit: Number(req.query.limit ?? 10), offset: Number(req.query.offset ?? 0), }); return res.json({ data: result.items, page: { total: result.total, offset: result.offset, limit: result.limit, has_more: result.hasMore, }, request_id: req.requestId, }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/agent/jobs/:jobId/cancel', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { return sendData( res, req, await mindSpaceAgentJobs.cancelJob(req.currentUser.id, req.params.jobId), ); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/agent/jobs/:jobId/retry', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { return sendData( res, req, await mindSpaceAgentJobs.retryJob(req.currentUser.id, req.params.jobId), ); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/agent/jobs/:jobId/run', async (req, res) => { if ( !mindSpaceAgentJobs || !mindSpaceAgentRunner || !ensureMindSpaceEnabled(res, req, { agent: true }) ) { return; } try { const job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); if (job.status !== 'queued') { return sendData(res, req, job); } void mindSpaceAgentRunner.runJob(req.params.jobId).catch((error) => { console.error('MindSpace agent job run failed:', error); }); return sendData(res, req, { started: true, jobId: req.params.jobId }, 202); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/internal/agent/jobs/:jobId/claim', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; if (!requireInternalAgentSecret(req, res)) return; try { return sendData(res, req, await mindSpaceAgentJobs.claimJob(req.params.jobId)); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/internal/agent/jobs/:jobId/assets/:assetId', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { const asset = await mindSpaceAgentJobs.getAssetForJob( req.params.jobId, bearerToken(req), req.params.assetId, ); res.set('Content-Type', asset.mimeType); res.set('Content-Disposition', `inline; filename="${encodeURIComponent(asset.displayName)}"`); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(await fs.promises.readFile(asset.path)); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/internal/agent/jobs/:jobId/heartbeat', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { return sendData( res, req, await mindSpaceAgentJobs.heartbeat(req.params.jobId, bearerToken(req), { stage: req.body?.stage, message: req.body?.message, }), ); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/internal/agent/jobs/:jobId/complete', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { const job = await mindSpaceAgentJobs.completeJob(req.params.jobId, bearerToken(req), { status: req.body?.status, errorCode: req.body?.error_code, errorMessage: req.body?.error_message, outputType: req.body?.output_type, title: req.body?.title, summary: req.body?.summary, content: req.body?.content, contentFormat: req.body?.content_format, pageType: req.body?.page_type, templateId: req.body?.template_id, sourceAssetIds: req.body?.source_asset_ids, }); return sendData(res, req, job); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationPackageHandler({ getRegistry: () => mindSpaceConversationPackageRegistry, getUserAuth: () => userAuth, getSessionAccess: () => sessionAccess, ensureMindSpaceEnabled, sendData, mindSpaceError, beforeReadManifest: ({ req, sessionId }) => mindSpaceServiceFacade?.prepareConversationPackageRead({ user: req.currentUser, sessionId, }), })); api.get( '/mindspace/v1/conversation-packages/:sessionId/manifest.json', createDownloadConversationPackageManifestHandler({ getRegistry: () => mindSpaceConversationPackageRegistry, getUserAuth: () => userAuth, getSessionAccess: () => sessionAccess, ensureMindSpaceEnabled, mindSpaceError, beforeReadManifest: ({ req, sessionId }) => mindSpaceServiceFacade?.prepareConversationPackageRead({ user: req.currentUser, sessionId, }), }), ); api.get( '/mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download', createDownloadConversationPackageArtifactHandler({ getRegistry: () => mindSpaceConversationPackageRegistry, getUserAuth: () => userAuth, getSessionAccess: () => sessionAccess, ensureMindSpaceEnabled, mindSpaceError, }), ); api.post('/mindspace/v1/uploads', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return; try { const upload = await mindSpaceAssets.createUpload(req.currentUser.id, { categoryId: req.body?.category_id, filename: req.body?.filename, sizeBytes: req.body?.size_bytes, declaredMimeType: req.body?.declared_mime_type, sourceSessionId: req.body?.session_id, sourceMessageId: req.body?.message_id, }); return sendData(res, req, upload, 201); } catch (error) { return mindSpaceError(res, req, error); } }); api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => { if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpaceAssets.writeUploadContent( req.currentUser.id, req.params.uploadId, req.body, ); return res.json({ data: result }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => { if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const asset = await mindSpaceAssets.completeUpload( req.currentUser.id, req.params.uploadId, ); return res.status(201).json({ data: asset }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/conversation-packages/:sessionId/claim-uploads', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return; try { const result = await mindSpaceAssets.claimUploadArtifactsForConversation(req.currentUser.id, { sessionId: req.params.sessionId, messageId: req.body?.message_id, }); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => { if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpaceAssets.cancelUpload( req.currentUser.id, req.params.uploadId, ); return res.json({ data: result }); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/assets', async (req, res) => { if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const assets = await mindSpaceAssets.listAssets(req.currentUser.id, { categoryId: typeof req.query.category_id === 'string' ? req.query.category_id : undefined, categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined, }); return res.json({ data: assets, page: { next_cursor: null, has_more: false } }); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/authorize-image', async (req, res) => { if (!mindSpacePublications) { return res.status(503).json({ error: 'Service unavailable' }); } const assetId = String(req.query.asset_id ?? ''); if (!assetId) { return res.status(400).json({ error: 'Missing asset_id parameter' }); } try { const [refs] = await pool.query( `SELECT pr.access_mode, pr.expires_at FROM h5_publication_asset_refs refs JOIN h5_publish_records pr ON refs.publication_id = pr.id WHERE refs.asset_id = ? AND pr.status = 'online' ORDER BY CASE WHEN pr.access_mode = 'public' THEN 0 WHEN pr.access_mode = 'time_limited' THEN 1 ELSE 2 END, pr.expires_at DESC LIMIT 1`, [assetId], ); if (!refs[0]) { return res.status(403).json({ error: 'Forbidden' }); } const publication = refs[0]; const now = Date.now(); if (publication.access_mode === 'public') { res.set('Cache-Control', 'public, max-age=31536000, immutable'); return res.status(200).json({ ok: true }); } if ( publication.access_mode === 'time_limited' && publication.expires_at && Number(publication.expires_at) > now ) { res.set('Cache-Control', 'public, max-age=60'); return res.status(200).json({ ok: true }); } return res.status(403).json({ error: 'Forbidden' }); } catch (error) { console.error('[authorize-image]', error instanceof Error ? error.message : error); return res.status(500).json({ error: 'Internal server error' }); } }); api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { const assetId = req.params.assetId; const currentUserId = req.currentUser?.id ?? req.userSession?.userId ?? null; let allowedByPublication = false; let publicationAccessMode = null; if (!currentUserId) { const referrer = String(req.get('referer') ?? req.get('referrer') ?? ''); const isPublicPageReferrer = (() => { if (!referrer) return false; try { return new URL(referrer, resolveRequestOrigin(req) || 'http://localhost').pathname.includes('/public/'); } catch { return referrer.includes('/public/'); } })(); const [refs] = await authPool.query( `SELECT pr.access_mode, pr.expires_at FROM h5_publication_asset_refs refs JOIN h5_publish_records pr ON refs.publication_id = pr.id WHERE refs.asset_id = ? AND pr.status = 'online' ORDER BY CASE WHEN pr.access_mode = 'public' THEN 0 WHEN pr.access_mode = 'time_limited' THEN 1 ELSE 2 END, pr.expires_at DESC LIMIT 1`, [assetId], ); const publication = refs[0] ?? null; if (publication?.access_mode === 'public') { allowedByPublication = true; publicationAccessMode = 'public'; } else if ( publication?.access_mode === 'time_limited' && publication.expires_at && Number(publication.expires_at) > Date.now() ) { allowedByPublication = true; publicationAccessMode = 'time_limited'; } if (!allowedByPublication && verifyPublicAssetToken(assetId, req.query.public_token, INTERNAL_AGENT_SECRET)) { allowedByPublication = true; publicationAccessMode = 'signed-public-token'; } if (!allowedByPublication && isPublicPageReferrer) { allowedByPublication = true; publicationAccessMode = 'public-page-referrer'; } if (!allowedByPublication) { return res.status(401).json({ message: '未授权,请重新登录' }); } } const { asset, path: assetPath } = currentUserId ? await mindSpaceAssets.readAsset(currentUserId, assetId) : await mindSpaceAssets.readPublicAsset(assetId); await mindSpaceAudit?.write({ userId: currentUserId, action: 'asset.download', objectType: 'asset', objectId: assetId, ip: req.ip, riskLevel: asset.riskLevel, detail: allowedByPublication ? { accessMode: publicationAccessMode } : undefined, }); res.type(asset.mimeType); const inline = req.query.inline === '1' || req.query.disposition === 'inline' || req.get('sec-fetch-dest') === 'iframe'; if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) { const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`; const html = renderImageAssetViewerHtml({ asset, downloadUrl }); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(html); } res.set( 'Content-Disposition', inline ? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}` : `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`, ); res.setHeader('X-Request-Id', req.requestId); return res.sendFile(assetPath); } catch (error) { await mindSpaceAudit?.write({ userId: req.currentUser?.id ?? null, action: 'asset.download', objectType: 'asset', objectId: req.params.assetId, ip: req.ip, result: 'denied', riskLevel: error?.code === 'security_risk_blocked' ? 'high' : null, }); return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'private, max-age=300'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { const html = await mindSpaceAssets.renderAssetPreview(req.currentUser.id, req.params.assetId); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(html); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/from-asset', async (req, res) => { if (!mindSpacePages || !mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const assetId = req.body?.asset_id; const existingPage = await mindSpacePages.findPageBySourceAsset(req.currentUser.id, assetId); if (existingPage) { return sendData(res, req, { kind: 'page', categoryCode: existingPage.categoryCode ?? 'draft', page: existingPage }); } const { asset, path: assetPath } = await mindSpaceAssets.readAsset( req.currentUser.id, assetId, ); if (asset.mimeType === 'text/html') { const relativePath = normalizeWorkspaceRelativePath( String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') ? asset.originalFilename ?? asset.original_filename : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, ); const existingByPath = relativePath ? await mindSpacePages.findPageByRelativePath(req.currentUser.id, relativePath).catch(() => null) : null; if (existingByPath) { return sendData(res, req, { kind: 'page', categoryCode: existingByPath.categoryCode ?? 'draft', page: existingByPath, }); } } const content = await fs.promises.readFile(assetPath, 'utf8'); const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown'; const htmlRelativePath = contentFormat === 'html' ? normalizeWorkspaceRelativePath( String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') ? asset.originalFilename ?? asset.original_filename : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, ) : null; const page = await mindSpacePages.createFromChat( req.currentUser.id, { title: req.body?.title || asset.displayName, summary: req.body?.summary, content, contentFormat, templateId: req.body?.template_id, categoryCode: 'draft', }, { assetId: asset.id, snapshot: { source_asset_id: asset.id, source_category: asset.categoryCode, content_mode: contentFormat, ...(htmlRelativePath ? { relative_path: htmlRelativePath } : {}), }, }, ); return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } }); } catch (error) { return mindSpaceError(res, req, error); } }); api.delete('/mindspace/v1/assets/:assetId', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { const result = await mindSpaceAssets.deleteAsset(req.currentUser.id, req.params.assetId); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'asset.delete', objectType: 'asset', objectId: req.params.assetId, ip: req.ip, }); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); function messageText(message) { return (message?.content ?? []) .filter((item) => item?.type === 'text' && typeof item.text === 'string') .map((item) => item.text) .join('') .trim(); } async function resolveOwnedAssistantMessage(user, sessionId, messageId) { const userId = user?.id; if (!sessionId || !messageId) { throw Object.assign(new Error('缺少来源会话或消息'), { code: 'invalid_page_input', }); } if (!(await ownsAgentSession(userId, sessionId))) { throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' }); } let session = null; if (sessionSnapshotService?.isEnabled()) { const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null); if (snapshot?.messages?.length) { session = { ...snapshot.session, conversation: sanitizeSessionConversationPublicHtmlLinks(snapshot.messages, user), }; if (authPool) { session = await repairSessionConversationFromDb(authPool, session, sessionId, userId); } } } if (!session) { const target = await tkmindProxy.resolveTarget(sessionId); let upstream; try { upstream = await tkmindProxy.apiFetchTo( target, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', signal: AbortSignal.timeout(15_000) }, ); } catch (error) { throw Object.assign( new Error( error?.name === 'TimeoutError' || error?.name === 'AbortError' ? '读取来源会话超时,请稍后重试' : '无法读取来源会话', ), { code: 'source_message_not_found' }, ); } if (!upstream.ok) { throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' }); } session = await upstream.json(); if (Array.isArray(session.conversation)) { session.conversation = sanitizeSessionConversationPublicHtmlLinks(session.conversation, user); } if (authPool) { session = await repairSessionConversationFromDb(authPool, session, sessionId, userId); } } const message = (session.conversation ?? []).find((item) => item.id === messageId); if (!message) { throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' }); } const content = messageText(message); if (message.role !== 'assistant' || !message.metadata?.userVisible || !content) { throw Object.assign(new Error('只有可见的 AI 文本消息可以保存为页面'), { code: 'invalid_source_message', }); } return { session, message, content }; } async function resolveExistingSavedPage(userId, { sessionId, messageId, relativePath } = {}) { if (!mindSpacePages || !userId) return null; const byMessage = await mindSpacePages .findPageBySourceMessage(userId, sessionId, messageId) .catch(() => null); if (byMessage) return byMessage; const normalizedPath = normalizeWorkspaceRelativePath(relativePath); if (!normalizedPath) return null; return mindSpacePages.findPageByRelativePath(userId, normalizedPath).catch(() => null); } const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); // REGRESSION GUARD: mindspace-page-sync-thumbnail — remote 也经 pageSyncService RPC 同步 public HTML async function listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs = null } = {}) { if (!authPool || !userId || !sessionId) return []; const sinceClause = sinceMs == null ? '' : 'AND ca.created_at >= ?'; const params = sinceMs == null ? [userId, sessionId] : [userId, sessionId, sinceMs]; const [rows] = await authPool.query( `SELECT ca.display_name FROM h5_conversation_artifacts ca JOIN h5_conversation_packages cp ON cp.id = ca.package_id WHERE cp.user_id = ? AND cp.session_id = ? AND ca.artifact_kind = 'public_html' ${sinceClause} ORDER BY ca.sort_order ASC, ca.created_at ASC`, params, ); return [...new Set((rows ?? []) .map((row) => normalizeWorkspaceRelativePath(`public/${String(row.display_name ?? '').trim()}`)) .filter((relativePath) => relativePath?.startsWith('public/') && relativePath.toLowerCase().endsWith('.html')))]; } async function syncUserGeneratedPages(userId, { sessionId = null, sinceMs = null } = {}) { if (!userId) return; // Agent-run/Finish delivery must stay scoped to the current conversation. // A stale Page Data page elsewhere in the user's workspace must not turn a // successfully completed current task into a failed run. const discoveredRelativePaths = sessionId ? await listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs }) : null; // A normal static-page-publish flow may only leave a workspace HTML file and // no conversation artifact. Discover only files written around this run; // `null` would rescan every historical page and let stale Page Data pages // block an unrelated delivery. const recentWorkspaceRelativePaths = sessionId && !discoveredRelativePaths?.length ? listRecentlyModifiedPublicHtmlRelativePaths( resolveMindSpaceUserPublishDir(__dirname, { id: userId }), { sinceMs }, ) : []; const pageDataRelativePaths = sessionId ? (discoveredRelativePaths?.length ? discoveredRelativePaths : recentWorkspaceRelativePaths) : null; if (workspacePageDeliver?.syncAndDeliver) { return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths }); } if (!mindSpacePageSync) return; return await mindSpacePageSync.syncUserGeneratedPages(userId); } async function resolveChatSaveBundle(user, h5Root, input = {}) { const sessionId = input.sessionId ?? input.session_id; const messageId = input.messageId ?? input.message_id; const selectedLinkIndex = Number(input.selectedLinkIndex ?? input.selected_link_index ?? 0); const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim(); const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim(); const source = await resolveOwnedAssistantMessage(user, sessionId, messageId); let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ content: source.content, userId: user.id, username: user.username, h5Root, selectedLinkIndex, }); if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) { const { storageRoot } = resolveMindSpaceRuntimeConfig(h5Root, process.env); let htmlContent = resolvedHtml.content; const repaired = await repairMissingHtmlAssetReferences({ pool: authPool, userId: user.id, sourceContent: source.content, html: htmlContent, }).catch(() => ({ html: htmlContent, changed: false })); if (repaired.changed) { htmlContent = repaired.html; } const materialized = await materializePrivateAssetsInWorkspaceHtml({ pool: authPool, storageRoot, h5Root, userId: user.id, html: htmlContent, htmlRelativePath: resolvedHtml.relativePath, writeBack: false, }).catch(() => ({ html: htmlContent, count: 0, changed: false })); if (materialized.changed) { htmlContent = materialized.html; } if (htmlContent !== resolvedHtml.content) { const publishDir = resolveMindSpaceUserPublishDir(h5Root, { id: user.id }); await fsPromises.writeFile( path.join(publishDir, resolvedHtml.relativePath), htmlContent, 'utf8', ); resolvedHtml = { ...resolvedHtml, content: htmlContent }; } } return { source, analysis, resolvedHtml, selectedLinkIndex, previewTitle, previewSummary, previewFrameUrl: resolvedHtml != null ? buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex }) : null, thumbnailUrl: resolvedHtml != null ? buildChatSaveThumbnailUrl({ sessionId, messageId, selectedLinkIndex, previewTitle, previewSummary, }) : null, localPreviewUrl: resolvedHtml?.relativePath != null ? buildWorkspaceAssetUrl(user.id, resolvedHtml.relativePath) : null, localThumbnailUrl: resolvedHtml?.relativePath != null ? buildWorkspaceThumbnailUrl(user.id, resolvedHtml.relativePath) : null, }; } async function registerPublishedLongImageArtifactForConversation({ result, image, canonicalUrl, }) { const ownerId = String(result?.ownerId ?? '').trim(); const pageSource = result?.pageSource ?? {}; const sessionId = String(pageSource.sourceSessionId ?? '').trim(); const messageId = String(pageSource.sourceMessageId ?? '').trim(); const publicationId = String(result?.publication?.id ?? '').trim(); if ( !mindSpaceConversationPackageRegistry || !ownerId || !sessionId || !publicationId || !Buffer.isBuffer(image) ) { return; } try { const hash = crypto .createHash('sha256') .update(`${ownerId}:${sessionId}:${publicationId}:long-image`) .digest('hex') .slice(0, 16); const artifactId = `ca_long_image_${hash}`; const filename = `${publicationId || 'published-page'}.long.png`; const relativePath = `artifacts/${artifactId}/${filename}`; const now = Date.now(); const { packageRecord, writeResult } = await mindSpaceConversationPackageRegistry.putObjectForSession({ userId: ownerId, sessionId, title: pageSource.title ?? null, relativePath, body: image, }); await mindSpaceConversationPackageRegistry.recordArtifact({ id: artifactId, packageId: packageRecord.id, artifactKind: 'long_image', role: 'assistant', pageId: pageSource.pageId ?? result?.publication?.pageId ?? null, publicationId, messageId: messageId || null, displayName: filename, mimeType: 'image/png', sizeBytes: image.length, storageKey: writeResult.key, canonicalUrl, sortOrder: now, now, }); await mindSpaceConversationPackageRegistry.writeManifestForSession({ userId: ownerId, sessionId, }); } catch (error) { console.warn('[MindSpace] failed to record published long image artifact:', error?.message ?? error); } } async function registerPublicHtmlArtifactsForConversation({ user, publishDir, sessionId, title = null, relativePaths = [], artifactRefs = [], }) { return registerPublicHtmlArtifactsForConversationPackage({ conversationPackageRegistry: mindSpaceConversationPackageRegistry, h5Root: __dirname, env: process.env, user, publishDir, sessionId, title, relativePaths, artifactRefs, }); } api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query); if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } const baseHref = buildWorkspaceBaseHref(req.currentUser.id, bundle.resolvedHtml.relativePath); const html = injectHtmlBaseHref(bundle.resolvedHtml.content, baseHref); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(html); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query); if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath); const title = bundle.previewTitle || bundle.resolvedHtml.suggestedTitle || bundle.analysis.suggestedTitle; const subtitle = bundle.previewSummary || bundle.resolvedHtml.suggestedSummary || bundle.analysis.suggestedSummary; await ensureWorkspaceHtmlThumbnail( publishDir, bundle.resolvedHtml.relativePath, bundle.resolvedHtml.content, { title, subtitle, force: true }, ).catch(() => {}); const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); const resolveAssetDataUri = createAssetDataUriResolver( authPool, storageRoot, req.currentUser.id, ); const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, { title, subtitle, contentBaseDir: path.dirname(bundle.resolvedHtml.absolute), resolveAssetDataUri, force: true, }); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const sessionId = req.body?.session_id; const messageId = req.body?.message_id; const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); const { source, analysis, resolvedHtml, previewTitle, previewSummary, previewFrameUrl, thumbnailUrl, localPreviewUrl, localThumbnailUrl, } = bundle; let thumbnailReady = false; if (resolvedHtml?.content && resolvedHtml.relativePath) { const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); try { await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, { title: previewTitle || resolvedHtml.suggestedTitle, subtitle: previewSummary || resolvedHtml.suggestedSummary, force: Boolean(previewTitle || previewSummary), }); thumbnailReady = true; } catch { thumbnailReady = false; } } const existingPage = await resolveExistingSavedPage(req.currentUser.id, { sessionId, messageId, relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, }); return sendData(res, req, { contentMode: analysis.contentMode, links: analysis.links, selectedLinkIndex: analysis.selectedLink ? analysis.links.findIndex((link) => link.publicUrl === analysis.selectedLink.publicUrl) : -1, suggestedTitle: resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle, suggestedSummary: resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary, previewUrl: analysis.previewUrl, previewFrameUrl, localPreviewUrl, localThumbnailUrl, thumbnailUrl: resolvedHtml ? thumbnailUrl : null, thumbnailReady, relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, filename: resolvedHtml?.filename ?? analysis.filename, hasHtmlContent: Boolean(resolvedHtml), privacyScan: scanContent(resolvedHtml?.content ?? source.content), existingPage: existingPage ? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode } : null, }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml)); if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); const { html: localizedHtml } = await inlinePrivateAssetsInHtml( authPool, storageRoot, req.currentUser.id, bundle.resolvedHtml.content, ); const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared'); await fsPromises.mkdir(sharedDir, { recursive: true }); const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, ''); const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`; const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`; const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath); const destPath = path.join(sharedDir, filename); await fsPromises.writeFile(destPath, sharedHtml, 'utf8'); const publishKey = req.currentUser.id; const publicUrl = buildMindSpacePublicUrlForUser({ h5Root: __dirname, env: process.env, user: publishKey, relativePath: sharedRelativePath, }); return res.status(201).json({ data: { publicUrl, filename } }); } catch (error) { console.error('[quick-share] error:', error); return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用'); } const startedAt = Date.now(); try { const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); const result = await quickPlazaFromChat({ user: req.currentUser, h5Root: __dirname, bundle, body: req.body ?? {}, mindSpacePages, mindSpacePublications, plazaPosts, publishDir, }); console.info('[quick-plaza] ok', { ms: Date.now() - startedAt, pageId: result.pageId, publicationId: result.publicationId, postId: result.post?.id, }); return sendData(res, req, result, 201); } catch (error) { console.error('[quick-plaza] error:', { ms: Date.now() - startedAt, error }); if (error?.code && mapPlazaError(error) !== 500) { return plazaRouteError(res, req, error); } return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/quick-plaza-from-public-html/status', async (req, res) => { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用'); } try { const relativePath = String(req.query?.relative_path ?? req.query?.relativePath ?? '').trim(); const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); const result = await getQuickPlazaFromPublicHtmlStatus({ user: req.currentUser, relativePath, mindSpacePages, mindSpacePublications, plazaPosts, }); const postId = result.post?.id; return sendData(res, req, { ...result, plaza_url: postId ? resolvePlazaPostUrlForRequest(postId, req) : null, }); } catch (error) { if (error?.code && mapPlazaError(error) !== 500) { return plazaRouteError(res, req, error); } return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/quick-plaza-from-public-html', async (req, res) => { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用'); } const startedAt = Date.now(); try { const relativePath = String(req.body?.relative_path ?? req.body?.relativePath ?? '').trim(); const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); const result = await quickPlazaFromPublicHtml({ user: req.currentUser, relativePath, mindSpacePages, mindSpacePublications, plazaPosts, publishDir, }); const plazaUrl = resolvePlazaPostUrlForRequest(result.post.id, req); console.info('[quick-plaza-public-html] ok', { ms: Date.now() - startedAt, pageId: result.pageId, publicationId: result.publicationId, postId: result.post?.id, relativePath, }); return sendData(res, req, { ...result, plaza_url: plazaUrl }, 201); } catch (error) { console.error('[quick-plaza-public-html] error:', { ms: Date.now() - startedAt, error }); if (error?.code === 'ALREADY_PUBLISHED') { const postId = error?.details?.post_id ?? error?.details?.postId; if (postId) { error.details = { ...(error.details ?? {}), plaza_url: resolvePlazaPostUrlForRequest(postId, req), }; } } if (error?.code && mapPlazaError(error) !== 500) { return plazaRouteError(res, req, error); } return mindSpaceError(res, req, error); } }); async function handleChatSaveDocx(req, res) { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); const title = bundle.previewTitle || bundle.resolvedHtml?.suggestedTitle || bundle.analysis?.suggestedTitle || 'AI 创作文档'; const content = bundle.resolvedHtml?.content ?? bundle.source.content; const contentFormat = bundle.resolvedHtml?.content ? 'html' : 'markdown'; const buffer = generateDocxBuffer({ title, content, contentFormat }); const filenameBase = String(title) .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60) || 'mindspace-document'; const filename = `${filenameBase}.docx`; await registerChatDocxArtifactForConversation({ registry: mindSpaceConversationPackageRegistry, user: req.currentUser, bundle, requestBody: req.body, filename, buffer, }).catch((error) => { console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error); }); res.set('Content-Type', DOCX_MIME_TYPE); res.set( 'Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`, ); res.set('Cache-Control', 'no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(buffer); } catch (error) { return mindSpaceError(res, req, error); } } api.post('/mindspace/v1/pages/chat-save-docx', handleChatSaveDocx); api.post('/v1/pages/chat-save-docx', handleChatSaveDocx); api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { if (!mindSpacePages || !mindSpaceAssets) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const categoryCode = String(req.body?.category_code ?? 'draft'); if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) { throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' }); } const source = await resolveOwnedAssistantMessage( req.currentUser, req.body?.session_id, req.body?.message_id, ); const analysis = analyzeChatMessageForSave({ content: source.content, userId: req.currentUser.id, username: req.currentUser.username, h5Root: __dirname, selectedLinkIndex: Number(req.body?.selected_link_index ?? 0), }); const snapshot = { session_name: source.session.name, message_created: source.message.created, role: source.message.role, content_mode: analysis.contentMode, public_url: analysis.previewUrl, relative_path: analysis.relativePath ? normalizeWorkspaceRelativePath(analysis.relativePath) : analysis.relativePath, }; let resolvedHtml = null; if (analysis.contentMode === 'static_html') { resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null); } const privacyScan = scanContent(resolvedHtml?.content ?? source.content); if (categoryCode !== 'draft') { let buffer; let filename; let displayName = req.body?.title; if (analysis.contentMode === 'static_html') { if (!resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } buffer = Buffer.from(resolvedHtml.content, 'utf8'); filename = resolvedHtml.filename; displayName = displayName || resolvedHtml.suggestedTitle; } else { buffer = Buffer.from(source.content, 'utf8'); filename = `${String(displayName || 'chat-export') .replace(/[^\w\u4e00-\u9fff-]+/g, '-') .slice(0, 48) || 'chat-export'}.md`; } const asset = await mindSpaceAssets.createChatAsset(req.currentUser.id, { categoryCode, buffer, filename, displayName, sourceType: 'chat', }); return res.status(201).json({ data: { kind: 'asset', categoryCode, asset, }, }); } let pageInput = { title: req.body?.title, summary: req.body?.summary, templateId: req.body?.template_id, pageType: req.body?.page_type, categoryCode: 'draft', }; if (analysis.contentMode === 'static_html') { if (!resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } pageInput = { ...pageInput, title: req.body?.title || resolvedHtml.suggestedTitle, summary: req.body?.summary || resolvedHtml.suggestedSummary, content: resolvedHtml.content, contentFormat: 'html', pageType: 'html', }; } else { pageInput = { ...pageInput, content: source.content, contentFormat: 'markdown', }; } if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) { const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { title: pageInput.title, subtitle: pageInput.summary, }).catch(() => {}); } const saveAsNew = Boolean(req.body?.save_as_new); let replacePageId = req.body?.replace_page_id ? String(req.body.replace_page_id).trim() : null; if (!replacePageId && !saveAsNew && analysis.contentMode === 'static_html' && analysis.relativePath) { const existingByPath = await mindSpacePages .findPageByRelativePath(req.currentUser.id, analysis.relativePath) .catch(() => null); if (existingByPath) replacePageId = existingByPath.id; } let page; if (replacePageId) { const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId); page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, { ...pageInput, expectedVersion: existingPage.versionNo, }); } else { page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, { sessionId: req.body.session_id, messageId: req.body.message_id, snapshot, }); } return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const page = await mindSpacePages.createPage(req.currentUser.id, { title: req.body?.title, summary: req.body?.summary, content: req.body?.content, contentFormat: req.body?.content_format === 'html' ? 'html' : undefined, templateId: req.body?.template_id, pageType: req.body?.page_type, categoryCode: req.body?.category_code, }); return res.status(201).json({ data: page }); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { await syncUserGeneratedPages(req.currentUser.id); const limit = Number.parseInt(String(req.query.limit ?? ''), 10); const offset = Number.parseInt(String(req.query.offset ?? ''), 10); const result = await mindSpacePages.listPages(req.currentUser.id, { status: typeof req.query.status === 'string' ? req.query.status : undefined, categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined, ...(Number.isFinite(limit) ? { limit } : {}), ...(Number.isFinite(offset) ? { offset } : {}), }); return res.json({ data: result.items, page: { total: result.total, limit: result.limit, offset: result.offset, has_more: result.hasMore, next_cursor: null, }, }); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/:pageId', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const [page, versions, publication] = await Promise.all([ mindSpacePages.getPage(req.currentUser.id, req.params.pageId), mindSpacePages.listVersions(req.currentUser.id, req.params.pageId), mindSpacePublications?.getCurrent(req.currentUser.id, req.params.pageId) ?? null, ]); return res.json({ data: { ...page, versions, publication } }); } catch (error) { return mindSpaceError(res, req, error); } }); api.delete('/mindspace/v1/pages/:pageId', async (req, res) => { if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return; try { const removeFromPlaza = String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? '').toLowerCase() === 'true' || req.query?.remove_from_plaza === '1' || req.body?.remove_from_plaza === true; const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, { removeFromPlaza, }); const quota = await mindSpace.getQuota(req.currentUser.id); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.delete', objectType: 'page', objectId: req.params.pageId, ip: req.ip, detail: { offlinedPublicationCount: result.offlinedPublicationCount, hiddenPlazaPostCount: result.hiddenPlazaPostCount, deletedAssetCount: result.deletedAssetCount, freedBytes: result.freedBytes, }, }); return sendData(res, req, { ...result, quota }); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/:pageId/delete-preview', async (req, res) => { if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return; try { return sendData(res, req, await mindSpacePages.getDeletePreview(req.currentUser.id, req.params.pageId)); } catch (error) { return mindSpaceError(res, req, error); } }); api.put('/mindspace/v1/pages/:pageId', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const page = await mindSpacePages.updatePage(req.currentUser.id, req.params.pageId, { expectedVersion: req.body?.expected_version, title: req.body?.title, summary: req.body?.summary, content: req.body?.content, templateId: req.body?.template_id, pageType: req.body?.page_type, changeNote: req.body?.change_note, }); return res.json({ data: page }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => { if (!mindSpacePageLiveEdit || !mindSpacePages) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const sessionId = String(req.body?.session_id ?? '').trim(); if (!sessionId) { return sendError(res, req, 400, 'invalid_request', '缺少 session_id'); } const owns = await ownsAgentSession(req.currentUser.id, sessionId); if (!owns) { return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话'); } await mindSpacePages.getPage(req.currentUser.id, req.params.pageId); const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null; return sendData( res, req, mindSpacePageLiveEdit.bindSession({ userId: req.currentUser.id, sessionId, pageId: req.params.pageId, parentSessionId, }), ); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => { if (!mindSpacePageEditSession || !mindSpacePages) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const parentSessionId = String(req.body?.parent_session_id ?? '').trim(); if (!parentSessionId) { return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id'); } const gate = await userAuth.canUseChat(req.currentUser.id); if (!gate.ok) { return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message); } return sendData( res, req, await mindSpacePageEditSession.forkSession({ userId: req.currentUser.id, pageId: req.params.pageId, parentSessionId, h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null, }), ); } catch (error) { if (error?.code === 'forbidden') { return sendError(res, req, 403, error.code, error.message); } if (error?.code === 'invalid_request') { return sendError(res, req, 400, error.code, error.message); } return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => { if (!mindSpacePageEditSession || !mindSpacePages) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const sessionId = String(req.body?.session_id ?? '').trim(); if (!sessionId) { return sendError(res, req, 400, 'invalid_request', '缺少 session_id'); } return sendData( res, req, await mindSpacePageEditSession.closeSession({ userId: req.currentUser.id, pageId: req.params.pageId, sessionId, parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null, summary: String(req.body?.summary ?? ''), }), ); } catch (error) { if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') { return sendError(res, req, 403, error.code, error.message); } if (error?.code === 'invalid_request') { return sendError(res, req, 400, error.code, error.message); } return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => { if (!mindSpacePageLiveEdit || !mindSpacePages) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { return sendData( res, req, await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId), ); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/agent/mindspace_page_patch', async (req, res) => { if (!mindSpacePageLiveEdit) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {}); return res.json({ data: { pageId: result.page.id, title: result.page.title, summary: result.page.summary, versionNo: result.page.versionNo, updatedAt: result.page.updatedAt, liveRevision: result.liveRevision, }, }); } catch (error) { if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') { return sendError(res, req, 403, error.code, error.message); } if (error?.code === 'invalid_request') { return sendError(res, req, 400, error.code, error.message); } return mindSpaceError(res, req, error); } }); api.post('/agent/mindspace_asset_delete', async (req, res) => { if (!mindSpaceAssetAgent) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const result = await mindSpaceAssetAgent.applyAgentDelete(req.body ?? {}); await mindSpaceAudit?.write({ userId: result.userId ?? null, action: 'asset.delete', objectType: 'asset', objectId: result.assetId, ip: req.ip, metadata: { via: 'agent' }, }); return sendData(res, req, result); } catch (error) { if (error?.code === 'forbidden') { return sendError(res, req, 403, error.code, error.message); } if (error?.code === 'invalid_request' || error?.code === 'confirmation_required') { return sendError(res, req, 400, error.code, error.message); } return mindSpaceError(res, req, error); } }); api.post('/agent/mindspace_asset_download', async (req, res) => { if (!mindSpaceAssetAgent) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const result = await mindSpaceAssetAgent.applyAgentDownload(req.body ?? {}); await mindSpaceAudit?.write({ userId: result.userId ?? null, action: 'asset.download', objectType: 'asset', objectId: result.assetId, ip: req.ip, metadata: { via: 'agent', relativePath: result.relativePath ?? null }, }); return sendData(res, req, result); } catch (error) { if (error?.code === 'forbidden') { return sendError(res, req, 403, error.code, error.message); } if (error?.code === 'invalid_request' || error?.code === 'asset_not_found') { return sendError(res, req, 400, error.code, error.message); } return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'private, max-age=300'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/thumbnail/upload', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePages.uploadThumbnail(req.currentUser.id, req.params.pageId, { imageBase64: req.body?.image_base64, mimeType: req.body?.mime_type, title: req.body?.title, summary: req.body?.summary, html: req.body?.html, }); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePages.regenerateThumbnail( req.currentUser.id, req.params.pageId, { html: req.body?.html, title: req.body?.title, summary: req.body?.summary, useAi: Boolean(req.body?.use_ai), instruction: req.body?.instruction, }, { suggestCoverMeta: req.body?.use_ai ? (input) => { if (!authPool) { throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' }); } return suggestCoverMetaWithAi(authPool, { ...input, encryptionKey: process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY, }); } : null, }, ); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/rewrite-download-links', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const content = String(req.body?.content ?? '').trim(); if (!content) { throw Object.assign(new Error('缺少页面内容'), { code: 'invalid_page_input' }); } const html = await mindSpacePages.rewriteHtmlDownloadLinksForPage( req.currentUser.id, req.params.pageId, content, ); return sendData(res, req, { html }); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/:pageId/preview', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const { html, contentFormat } = await mindSpacePages.renderPreview( req.currentUser.id, req.params.pageId, ); res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', pageInternals.previewContentSecurityPolicy(contentFormat), ); res.set('Cache-Control', 'private, no-store'); res.set('X-Content-Type-Options', 'nosniff'); return res.send(html); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/preview-draft', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const { html, contentFormat } = await mindSpacePages.renderDraftPreview( req.currentUser.id, req.params.pageId, { title: req.body?.title, summary: req.body?.summary, content: req.body?.content, templateId: req.body?.template_id, }, ); res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', pageInternals.previewContentSecurityPolicy(contentFormat), ); res.set('Cache-Control', 'private, no-store'); res.set('X-Content-Type-Options', 'nosniff'); return res.send(html); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/publish-check', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePublications.check(req.currentUser.id, req.params.pageId, { pageVersionId: req.body?.page_version_id, accessMode: req.body?.access_mode, urlSlug: req.body?.url_slug, password: req.body?.password, expiresAt: req.body?.expires_at, }); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, { pageVersionId: req.body?.page_version_id, expectedVersion: req.body?.expected_version, title: req.body?.title, summary: req.body?.summary, content: req.body?.content, }); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.redact', objectType: 'page', objectId: result.page.id, ip: req.ip, riskLevel: result.originalScan.riskLevel, }); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/publish-fix', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePages.localizePrivateResources(req.currentUser.id, req.params.pageId, { pageVersionId: req.body?.page_version_id, expectedVersion: req.body?.expected_version, title: req.body?.title, summary: req.body?.summary, content: req.body?.content, }); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.publish_fix', objectType: 'page', objectId: result.page.id, ip: req.ip, riskLevel: result.originalScan.riskLevel, }); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/redacted-copy', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, { pageVersionId: req.body?.page_version_id, expectedVersion: req.body?.expected_version, title: req.body?.title, summary: req.body?.summary, content: req.body?.content, }); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.redact', objectType: 'page', objectId: result.page.id, ip: req.ip, riskLevel: result.originalScan.riskLevel, }); return sendData(res, req, result); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/:pageId/publish', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const publication = await mindSpacePublications.publish( req.currentUser.id, req.params.pageId, { pageVersionId: req.body?.page_version_id, accessMode: req.body?.access_mode, urlSlug: req.body?.url_slug, password: req.body?.password, expiresAt: req.body?.expires_at, acknowledgedFindingIds: req.body?.acknowledged_finding_ids, }, ); const workspaceRoot = req.currentUser.workspaceRoot ?? null; if (workspaceRoot) { const syncedPolicy = syncPageDataPolicyAccessMode( workspaceRoot, req.params.pageId, publication.accessMode, req.currentUser.id, ); if (syncedPolicy) { await upsertPageDataPolicyIndex(authPool, syncedPolicy).catch(() => null); } } await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.publish', objectType: 'publication', objectId: publication.id, ip: req.ip, }); return sendData(res, req, publication, 201); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/publications/:publicationId/update-status', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const publication = await mindSpacePublications.updatePublicationStatus( req.currentUser.id, req.params.publicationId, { accessMode: req.body?.access_mode, expiresAt: req.body?.expires_at, }, ); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'publication.status_updated', objectType: 'publication', objectId: req.params.publicationId, ip: req.ip, }); return sendData(res, req, publication); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/publications/:publicationId/offline', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const publication = await mindSpacePublications.offline( req.currentUser.id, req.params.publicationId, ); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.offline', objectType: 'publication', objectId: req.params.publicationId, ip: req.ip, }); return sendData(res, req, publication); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/publications/:publicationId/stats', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { return sendData( res, req, await mindSpacePublications.getStats( req.currentUser.id, req.params.publicationId, ), ); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/plaza/v1/categories', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; try { return sendData(res, req, { categories: await plazaPosts.listCategories() }); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/seo/sitemap', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用'); try { const data = await plazaSeo.listSitemapData({ postLimit: req.query.post_limit, userLimit: req.query.user_limit, }); return sendData(res, req, data); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/attribution/events', async (req, res) => { if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); try { const result = await plazaSeo.recordAttribution(req.body ?? {}, plazaClientIp(req)); return sendData(res, req, result, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/reports', async (req, res) => { if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const report = await plazaOps.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 plazaRouteError(res, req, error); } }); api.post('/plaza/v1/comments/:id/reports', async (req, res) => { if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const report = await plazaOps.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 plazaRouteError(res, req, error); } }); api.get('/plaza/v1/feed', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; try { const sessionId = resolvePlazaSessionId(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 plazaRouteError(res, req, error); } }); api.post('/plaza/v1/events', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!plazaEvents) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); try { const sessionId = String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(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 plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/reactions', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.addReaction( req.currentUser.id, req.params.id, req.body?.type, ); const eventType = reactionEventType(result.type); if (eventType) { recordPlazaEventsAsync(req, res, [{ event_type: eventType, post_id: req.params.id }]); } return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.removeReaction( req.currentUser.id, req.params.id, req.params.type, ); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/posts/:id/comments', async (req, res) => { if (!ensurePlazaInteractions(res, req)) 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 plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/comments', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const comment = await plazaInteractions.createComment(req.currentUser.id, req.params.id, req.body ?? {}); recordPlazaEventsAsync(req, res, [{ event_type: 'comment', post_id: req.params.id }]); return sendData(res, req, { comment }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/comments/:id', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const comment = await plazaInteractions.deleteComment(req.currentUser.id, req.params.id); return sendData(res, req, { comment }); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/comments/:id/reactions', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const liked = req.body?.liked !== false; const result = await plazaInteractions.toggleCommentLike(req.currentUser.id, req.params.id, liked); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/users/:slug', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; try { const profile = await plazaInteractions.getUserProfile( req.params.slug, req.currentUser?.id ?? null, ); return sendData(res, req, profile); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/users/:slug/posts', async (req, res) => { if (!ensurePlazaInteractions(res, req)) 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 plazaRouteError(res, req, error); } }); api.post('/plaza/v1/users/:slug/follow', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.followUser(req.currentUser.id, req.params.slug); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/users/:slug/follow', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.unfollowUser(req.currentUser.id, req.params.slug); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/posts/:id', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; try { const post = await plazaPosts.getPostById(req.params.id, { viewerId: req.currentUser?.id ?? null, }); void plazaRedis.recordView(req.params.id, plazaClientIp(req)).catch(() => {}); recordPlazaEventsAsync(req, res, [{ event_type: 'view', post_id: req.params.id }]); return sendData(res, req, { post }); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!req.currentUser) { return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); } try { const post = await plazaPosts.createPost(req.currentUser.id, req.body ?? {}); return sendData(res, req, { post }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.patch('/plaza/v1/posts/:id', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!req.currentUser) { return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); } try { const post = await plazaPosts.updatePost(req.currentUser.id, req.params.id, req.body ?? {}); return sendData(res, req, { post }); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/posts/:id', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!req.currentUser) { return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); } try { const post = await plazaPosts.hidePost(req.currentUser.id, req.params.id); return sendData(res, req, { post }); } catch (error) { return plazaRouteError(res, req, error); } }); function runHandlerChain(chain, req, res, next) { let index = 0; const run = (err) => { if (err) return next(err); const layer = chain[index++]; if (!layer) return; layer(req, res, (error) => run(error)); }; run(); } api.post('/llm/apply-local-fallback', async (req, res) => { // Client calls after creditsExhausted or relay 500 (see useTKMindChat). await userAuthReady; if (!userAuth || !llmProviderService || !tkmindProxy) { return res.status(503).json({ message: '未启用 LLM 配置' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const sessionId = String(req.body?.session_id ?? '').trim(); if (!sessionId) return res.status(400).json({ message: '缺少 session_id' }); const owns = await ownsAgentSession(me.id, sessionId); if (!owns) return res.status(403).json({ message: '无权访问该会话' }); try { const result = await tkmindProxy.applyLocalFallbackForSession(sessionId); if (!result.ok) return res.status(503).json(result); res.json(result); } catch (err) { res.status(500).json({ message: err instanceof Error ? err.message : '切换本地 LLM 失败', }); } }); api.post('/agent/start', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); return runHandlerChain(tkmindProxy.handlers['POST /agent/start'], req, res, next); }); api.post('/agent/runs', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); return runHandlerChain( [ tkmindProxy.requireUser, tkmindProxy.ensureChatAllowed, createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway, mindSpaceAssetAgent }), ], req, res, next, ); }); api.get('/agent/runs/:runId', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); return runHandlerChain( [ tkmindProxy.requireUser, createGetAgentRunHandler({ agentRunGateway }), ], req, res, next, ); }); api.get('/agent/runs/:runId/events', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); return runHandlerChain( [ tkmindProxy.requireUser, createAgentRunEventsHandler({ agentRunGateway }), ], req, res, next, ); }); api.post('/agent/resume', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); return runHandlerChain(tkmindProxy.handlers['POST /agent/resume'], req, res, next); }); api.get('/sessions', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); return runHandlerChain(tkmindProxy.handlers['GET /sessions'], req, res, next); }); // Session detail — serve from DB snapshot cache when fresh, fall through to Goose on miss. api.get('/sessions/:sessionId', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); const sessionId = req.params.sessionId; const owns = await ownsAgentSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } // Hints from the client (session list already has these values). const hintMc = req.query.hint_mc ? Number(req.query.hint_mc) : null; const hintUa = req.query.hint_ua ? String(req.query.hint_ua) : null; try { if (sessionSnapshotService?.isEnabled()) { let snapshot = await sessionSnapshotService.get(sessionId); if (snapshot) { if (authPool && await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, snapshot)) { await sessionSnapshotService.remove(sessionId).catch(() => {}); snapshot = null; } } if (snapshot) { // REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat. const canUseSnapshotCache = hintMc != null && hintUa != null; const mcMatch = snapshot.meta.synced_msg_count === hintMc; const uaMatch = snapshot.meta.source_updated_at === hintUa; if ( isDirectChatSessionId(sessionId) || isPortalDirectChatSnapshot(snapshot, { sessionId }) || (canUseSnapshotCache && mcMatch && uaMatch) ) { const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( snapshot.messages, req.currentUser, ); // Cache hit — reconstruct a Goose-compatible session response. let cachedGooseSession = { ...snapshot.session, // Embed only userVisible messages so getSession callers still work. conversation: sanitizedMessages, }; if (authPool) { cachedGooseSession = await repairSessionConversationFromDb( authPool, cachedGooseSession, sessionId, req.currentUser.id, ); } return res.json(cachedGooseSession); } } } } catch { // Snapshot read error: fall through silently to Goose. } // Cache miss — proxy to Goose and write-through on success. try { const target = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo( target, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET' }, ); if (!upstream.ok) { const text = await upstream.text().catch(() => ''); return res.status(upstream.status).send(text); } let gooseSession = await upstream.json(); if (Array.isArray(gooseSession.conversation)) { gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks( gooseSession.conversation, req.currentUser, ); } if (authPool) { gooseSession = await repairSessionConversationFromDb( authPool, gooseSession, sessionId, req.currentUser.id, ); } // Write-through: persist snapshot async, don't block the response. if (sessionSnapshotService?.isEnabled()) { const messages = filterNonemptyUserVisibleMessages(gooseSession.conversation ?? []); if (messages.length > 0) { void sessionSnapshotService .save(sessionId, req.currentUser.id, gooseSession, messages) .catch(() => {}); } } return res.json(gooseSession); } catch (err) { return res.status(502).json({ message: err instanceof Error ? err.message : '读取会话失败' }); } }); api.delete('/sessions/:sessionId', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); const sessionId = req.params.sessionId; const owns = await ownsAgentSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } try { if (isDirectChatSessionId(sessionId)) { await unregisterAgentSessionForUser(req.currentUser.id, sessionId); void sessionSnapshotService?.remove(sessionId).catch(() => {}); return res.status(204).end(); } const deleteTarget = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE', }); if (!upstream.ok && upstream.status !== 404) { const text = await upstream.text().catch(() => ''); return res.status(upstream.status).send(text || '删除会话失败'); } await unregisterAgentSessionForUser(req.currentUser.id, sessionId); // Remove snapshot so it doesn't linger after deletion. void sessionSnapshotService?.remove(sessionId).catch(() => {}); return res.status(204).end(); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '删除会话失败' }); } }); api.get('/sessions/:sessionId/events', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); const sessionId = req.params.sessionId; const owns = await ownsAgentSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } if (isDirectChatSessionId(sessionId)) { const snapshot = await sessionSnapshotService?.get(sessionId).catch(() => null); if (!snapshot) { return res.status(404).json({ message: '会话不存在' }); } return sendDirectChatSessionEvents(req, res, snapshot); } const portalDirectSnapshot = await sessionSnapshotService?.get(sessionId).catch(() => null); if ( portalDirectSnapshot && authPool && await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, portalDirectSnapshot) ) { await sessionSnapshotService?.remove(sessionId).catch(() => {}); } else if (portalDirectSnapshot && isPortalDirectChatSnapshot(portalDirectSnapshot, { sessionId })) { return sendDirectChatSessionEvents(req, res, portalDirectSnapshot); } const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id }); // `proxySessionEvents` deliberately invokes `onEvent` synchronously so an // async callback here would leave rejected database writes unhandled. // Retain every in-flight contract write and await it before marking files // deliverable after Finish. const deliveryContractWrites = new Map(); const generationAnalyticsEvents = new Set(); const syncPublicHtmlDuringStream = (event) => { const paths = collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir }); const eventMessages = event?.type === 'Message' && event.message ? [event.message] : event?.type === 'UpdateConversation' && Array.isArray(event.conversation) ? event.conversation : []; const pgRequired = eventMessages.some( (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, ); for (const relativePath of paths) { if (deliveryContractWrites.has(relativePath)) continue; const write = preparePageDeliveryContract({ pool: authPool, userId: req.currentUser.id, requestId: sid, relativePath, pgRequired, }).catch((error) => { console.warn(`[MindSpace] failed to prepare delivery contract for ${relativePath}: ${error?.message || error}`); return null; }); deliveryContractWrites.set(relativePath, write); } materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); for (const relativePath of paths) { const absolutePath = path.resolve(publishDir, relativePath); if (generationAnalyticsEvents.has(relativePath) || !fs.existsSync(absolutePath)) continue; generationAnalyticsEvents.add(relativePath); void sendMindSpaceAnalyticsEvent({ config: mindSpaceAnalyticsConfig, eventName: 'page_generated', ownerId: req.currentUser.id, ownerSegment: resolveAnalyticsOwnerSegment(req.currentUser), ownerLabel: resolveAnalyticsOwnerLabel(req.currentUser), pageId: relativePath, publicationId: sid, agentRunId: sid, channel: 'h5', url: `/${PUBLISH_ROOT_DIR}/${req.currentUser.id}/${relativePath}`, }); } }; // After Finish, refresh the snapshot and persist any newly generated public // workspace HTML into the asset store before a later restart rebuilds the // workspace from DB-backed assets only. const onAfterFinish = async (sid, uid) => { beginSessionPageDelivery(sid); try { const apiFetchFn = async (pathname, init) => { const target = await tkmindProxy.resolveTarget(sid); return tkmindProxy.apiFetchTo(target, pathname, init); }; let messages = null; if (sessionSnapshotService?.isEnabled()) { await sessionSnapshotService.refresh(sid, uid, apiFetchFn); messages = (await sessionSnapshotService.get(sid))?.messages ?? null; } else { try { const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: 'GET' }); if (upstream.ok) { const payload = await upstream.json().catch(() => null); messages = Array.isArray(payload?.conversation) ? payload.conversation.filter((message) => message?.metadata?.userVisible) : null; } } catch { messages = null; } } const syncResult = await syncPublicHtmlAfterFinish({ messages, currentUser: req.currentUser, publishDir, sessionId: sid, pool: authPool, storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, h5Root: __dirname, syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) : null, registerPublicHtmlArtifacts: (_userId, options) => registerPublicHtmlArtifactsForConversation({ user: req.currentUser, publishDir, sessionId: options?.sessionId, relativePaths: options?.relativePaths, artifactRefs: options?.artifactRefs, }), }); if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) { console.warn( `[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`, ); } const htmlDelivery = await maybeRepairH5HtmlAfterFinish({ sessionId: sid, userId: uid, currentUser: req.currentUser, messages, publishDir, syncResult, tkmindProxy, }); const lastUserMessage = [...(Array.isArray(messages) ? messages : [])] .reverse() .find((message) => message?.role === 'user'); const lastUserText = typeof lastUserMessage?.content === 'string' ? lastUserMessage.content : Array.isArray(lastUserMessage?.content) ? lastUserMessage.content .filter((item) => item?.type === 'text') .map((item) => String(item.text ?? '').trim()) .filter(Boolean) .join('\n') : ''; await syncUserGeneratedPages(uid, { sessionId: sid }); const pageDataDelivery = await maybeRepairPageDataAfterFinish({ sessionId: sid, userId: uid, publishDir, messages, pool: authPool, h5Root: __dirname, storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, tkmindProxy, userText: lastUserText, }); const htmlReady = htmlDelivery?.skipped === 'ok'; const pageDataReady = ['ok', 'not_page_data'].includes(String(pageDataDelivery?.skipped ?? '')); if (htmlReady && pageDataReady) { const publicHtmlRelativePaths = syncResult?.publicHtmlRelativePaths ?? []; const pgRequired = [...(Array.isArray(messages) ? messages : [])].some( (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, ); for (const relativePath of publicHtmlRelativePaths) { // A Finish-only write may not have reached the stream callback. This // also upgrades an early partial stream contract with the definitive // user delivery choice before it becomes ready. await (deliveryContractWrites.get(relativePath) ?? preparePageDeliveryContract({ pool: authPool, userId: uid, requestId: sid, relativePath, pgRequired, })); if (deliveryContractWrites.has(relativePath)) { await preparePageDeliveryContract({ pool: authPool, userId: uid, requestId: sid, relativePath, pgRequired, }); } await markPageDeliveryContractReady({ pool: authPool, userId: uid, relativePath, }).catch(() => false); } } if (lastUserMessage && memoryV2?.observePersonalMemory) { await memoryV2.observePersonalMemory({ userId: uid, sessionId: sid, messages: [lastUserMessage], }).catch((err) => { console.warn( `[memory-v2] finish shadow observation skipped for session ${sid}: ${err instanceof Error ? err.message : err}`, ); }); } } finally { endSessionPageDelivery(sid); } }; return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish, onEvent: syncPublicHtmlDuringStream, }); }); api.use(apiRequestBodyError); api.use(async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); if (isNativeH5ApiPath(req.path)) { return sendError(res, req, 404, 'not_found', `接口不存在:${req.method} ${req.path}`); } if (req.method === 'GET' && /^\/agent\/runs\/[^/]+\/events$/.test(req.path)) { return res.status(503).json({ message: 'Agent Run SSE 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端', code: 'AGENT_RUNS_NATIVE_REQUIRED', }); } const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/); if (sessionMatch) { const sessionId = sessionMatch[1]; if (req.path.endsWith('/events') && req.method === 'GET') { return next(); } if (req.path.endsWith('/reply') && req.method === 'POST') { return res.status(410).json({ message: '聊天提交入口已统一为 POST /agent/runs', code: 'AGENT_RUNS_REQUIRED', }); } const owns = await ownsAgentSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } } if (req.method === 'POST' && req.path === '/agent/resume' && req.body?.session_id) { const owns = await ownsAgentSession(req.currentUser.id, req.body.session_id); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } } if (req.body?.working_dir) { const allowed = await userAuth.isPathAllowed(req.currentUser.id, req.body.working_dir); if (!allowed) { return res.status(403).json({ message: '工作目录不在授权范围内' }); } } return tkmindProxy.proxyFallback(req, res); }); api.use( createProxyMiddleware({ target: API_TARGET, changeOrigin: true, secure: false, pathRewrite: { '^/api': '' }, on: { proxyReq: (proxyReq) => { proxyReq.setHeader('X-Secret-Key', API_SECRET); }, }, }), ); app.use('/api', api); app.use('/analytics', createProxyMiddleware({ target: 'http://127.0.0.1:3100', router: () => mindSpaceAnalyticsConfig.analyticsUrl || process.env.MEMIND_ANALYTICS_URL || 'http://127.0.0.1:3100', changeOrigin: true, secure: false, pathRewrite: { [`^${mindSpaceAnalyticsConfig.hostPath}`]: '' }, })); // Express routing is case-insensitive by default, so the lowercase /mindspace API // mount would otherwise capture public /MindSpace/... page URLs. app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => { await userAuthReady; return serveUserPublishFile(req, res, next); }); app.use('/mindspace', api); function extractOgImageUrl(html) { return ( String(html ?? '').match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] || String(html ?? '').match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] || '' ); } function extractShareMetaFromPageHtml(html) { const signals = extractCoverSignals(String(html ?? '')); return { title: detectPublishedPageTitle(html), subtitle: signals.subtitle, }; } function appendQueryParam(url, key, value) { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; } function removeQueryParam(url, key) { if (!url || !url.includes('?')) return url; const [base, queryAndHash] = url.split('?', 2); const [query, hash = ''] = queryAndHash.split('#', 2); const params = new URLSearchParams(query); params.delete(key); const nextQuery = params.toString(); return `${base}${nextQuery ? `?${nextQuery}` : ''}${hash ? `#${hash}` : ''}`; } function resolveRequestOrigin(req) { return resolvePublicRequestOrigin({ hostHeader: req.headers['x-forwarded-host'] || req.headers.host || '', forwardedProto: req.headers['x-forwarded-proto'] || '', protocol: req.protocol, }); } function detectPublishedPageTitle(html) { const match = String(html ?? '').match(/]*>([^<]+)<\/title>/i); return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面'; } function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) { const iframeSrc = escapePublicHtml(iframeUrl); const safeShareUrl = escapePublicHtml(shareUrl); const safeTitle = escapePublicHtml(title); const serializedShareUrl = JSON.stringify(shareUrl).replace(/ ${safeTitle}
`; } async function sendPublishedPage(req, res, result, { embed = false, raw = false, ownerSlug = null } = {}) { let html = rewriteBrokenMindSpacePublicImageUrls(result.html); if (ownerSlug) { html = rewritePublicationCanonicalAssetUrls(html, ownerSlug); } const origin = resolveRequestOrigin(req); const originalPath = req.originalUrl || req.url || ''; const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export'); const pageUrl = originalPath ? new URL(sharePath, origin || 'http://localhost').toString().split('#')[0] : ''; const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : ''; const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || ''); if (embed) { html = preparePublicationHtmlForEmbed(html); allowPlazaEmbedFrame(res); } else if (raw) { html = stripPublicationHtmlCspMeta(html); } html = injectPublishedPageDataContext(html, { pageSource: result.pageSource, publication: result.publication, }); if (!embed) { try { html = injectOgTags(html, { origin, pageUrl, pageDirUrl }); if (wechatShare) html = injectWechatShareBridge(html, { pageUrl }); } catch { // Never block public page delivery on share metadata injection. } } const isFullHtml = /^\s*]/i.test(html); if (!embed && !raw && isFullHtml && isLongImageDownloadRequest(req.query)) { try { const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost'); const image = await renderLongImageBuffer({ url: rawUrl.toString() }); const longImageUrl = new URL( appendQueryParam(sharePath || originalPath, 'download', 'long-image'), origin || 'http://localhost', ).toString(); await registerPublishedLongImageArtifactForConversation({ result, image, canonicalUrl: longImageUrl, }); res.set('Content-Type', 'image/png'); res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"'); res.set('Cache-Control', 'no-store'); return res.send(image); } catch (error) { return res .status(500) .type('text/plain; charset=utf-8') .send(`长图生成失败:${error?.message || '未知错误'}`); } } const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password'; if (canWrapWithShell) { const title = detectPublishedPageTitle(html); const rawUrl = appendQueryParam(sharePath || originalPath, 'view', 'raw'); const longImageUrl = appendQueryParam(sharePath || originalPath, 'download', 'long-image'); let shellHtml = publishedPageShellHtml({ iframeUrl: rawUrl, shareUrl: pageUrl, title, longImageUrl, }); try { shellHtml = injectOgTags(shellHtml, { origin, pageUrl, pageDirUrl, fallbackImageUrl: extractOgImageUrl(html), meta: extractShareMetaFromPageHtml(html), }); if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl }); } catch { // Keep the share shell usable even if metadata injection fails. } res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', wechatShare ? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" : "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", ); res.set( 'Cache-Control', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', ); return res.send(shellHtml); } res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', publishedPageCsp(html, { embed, raw, wechatShare, scriptHashes: collectInlineScriptHashes(html), }), ); res.set( 'Cache-Control', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', ); return res.send(html); } function passwordGateHtml(action) { return ` 受保护页面

此页面受密码保护

请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。

`; } function escapePublicHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function publicHomepageHtml(data) { const cards = data.pages .map( (page) => `\
${escapePublicHtml(page.templateId)} ${Number(page.viewCount)} 次浏览

${escapePublicHtml(page.title)}

${escapePublicHtml(page.summary || '暂无摘要')}

${new Date(page.publishedAt).toLocaleDateString('zh-CN')} 打开页面
`, ) .join(''); return ` ${escapePublicHtml(data.owner.displayName)} · MindSpace

MindSpace Public

${escapePublicHtml(data.owner.displayName)}

这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。

${Number(data.pageCount)} 个公开页面 ${Number(data.totalViews)} 次累计浏览 /u/${escapePublicHtml(data.owner.slug)}
${ data.pages.length ? `
${cards}
` : '
这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。
' }
`; } async function resolvePublishedRoute(req, res, password = null) { await userAuthReady; if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); try { const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null; const result = await mindSpacePublications.resolvePublic( req.params.ownerSlug, req.params.urlSlug, viewer?.id, password, { userAgent: req.get('user-agent'), referrer: req.get('referer'), }, ); const embed = isPlazaEmbedRequest(req.query); const raw = String(req.query.view ?? '').toLowerCase() === 'raw'; if ( !password && !embed && !raw && result.publication?.accessMode === 'public' && isPublicWorkspaceHtmlRelativePath(result.workspaceRelativePath) ) { const redirectPath = buildMindSpacePublicRoutePath( result.ownerId, String(result.workspaceRelativePath).split('/'), ); return res.redirect(301, redirectPath); } return await sendPublishedPage(req, res, result, { embed, raw, ownerSlug: req.params.ownerSlug, }); } catch (error) { if (error?.code === 'publication_password_required') { return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl)); } if (error?.code === 'publication_login_required') { return res.status(401).send('请先登录 TKMind 后再访问此页面'); } if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线'); return res.status(500).send('页面加载失败'); } } app.use(async (req, res, next) => { const thumbnailMatch = /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(req.path); if (!thumbnailMatch) return next(); const ownerSlug = thumbnailMatch[1]; const urlSlug = thumbnailMatch[2]; await userAuthReady; if (!authPool || !mindSpacePages) return res.status(503).send('MindSpace 未启用'); try { const [rows] = await authPool.query( `SELECT pr.user_id, pr.page_id FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online' ORDER BY pr.published_at DESC LIMIT 1`, [ownerSlug, urlSlug], ); const row = rows[0]; if (!row) return res.status(404).send('缩略图不存在'); const svg = await mindSpacePages.renderThumbnail(row.user_id, row.page_id); res.set('Content-Type', 'image/png'); res.set('Cache-Control', 'public, max-age=300'); return res.send(rasterizeThumbnailSvgToPng(svg)); } catch (error) { return res.status(500).send('缩略图加载失败'); } }); app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => { return resolvePublishedRoute(req, res); }); app.use('/u/:ownerSlug/public', async (req, res) => { await userAuthReady; const ownerSlug = String(req.params.ownerSlug ?? '').trim().toLowerCase(); if (!ownerSlug) return res.status(404).json({ message: '用户不存在' }); if (!authPool) return res.status(503).send('MindSpace 未启用'); try { const [rows] = await authPool.query( `SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`, [ownerSlug], ); const userId = rows[0]?.id ? String(rows[0].id) : null; if (!userId) return res.status(404).json({ message: '用户不存在' }); const relativePath = String(req.path ?? '') .replace(/^\/+/, '') .split('/') .map((segment) => decodeURIComponent(segment)) .filter(Boolean); if (relativePath.length === 0) return res.status(404).json({ message: '文件不存在' }); const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId }); const resolvedRoot = path.resolve(publishDir); const filePath = path.resolve(publishDir, PUBLIC_ZONE_DIR, ...relativePath); if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) { return res.status(403).json({ message: '禁止访问' }); } if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { return res.status(404).json({ message: '文件不存在' }); } return res.sendFile(filePath, (err) => { if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' }); }); } catch { return res.status(500).send('文件加载失败'); } }); app.get('/u/:ownerSlug', async (req, res) => { await userAuthReady; if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); try { const data = await mindSpacePublications.getPublicHomepage(req.params.ownerSlug); res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", ); res.set('Cache-Control', 'public, max-age=60'); return res.send(publicHomepageHtml(data)); } catch (error) { if (error?.code === 'publication_not_found') return res.status(404).send('主页不存在'); return res.status(500).send('主页加载失败'); } }); app.post( '/u/:ownerSlug/pages/:urlSlug', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => resolvePublishedRoute(req, res, req.body?.password), ); app.get('/s/:token', async (req, res) => { await userAuthReady; if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); try { const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null; return await sendPublishedPage( req, res, await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, { userAgent: req.get('user-agent'), referrer: req.get('referer'), }), { embed: isPlazaEmbedRequest(req.query), raw: String(req.query.view ?? '').toLowerCase() === 'raw', }, ); } catch (error) { if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线'); return res.status(500).send('页面加载失败'); } }); /** * Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover. * Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile. */ async function sendLongImageDownloadIfRequested(req, res, filePath) { const origin = resolveRequestOrigin(req); const currentUrl = origin ? `${origin}${req.originalUrl || req.url || ''}` : null; return handleMindSpaceLongImageDownload({ query: req.query, filePath, pageUrl: currentUrl ? removeQueryParam(removeQueryParam(currentUrl, 'download'), 'export') : null, res, isLongImageDownloadRequest, longImagePathForHtml, renderLongImage, }); } async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) { if (!authPool || !htmlReferencesPrivateAssets(html)) return html; const normalized = path.resolve(String(filePath ?? '')); const ownerMatch = normalized.match( new RegExp(`[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`, 'i'), ); const userId = ownerMatch?.[1] ?? null; if (!userId) return html; const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId }); const htmlRelativePath = path.relative(publishDir, normalized).replace(/\\/g, '/'); if (!htmlRelativePath.startsWith(`${PUBLIC_ZONE_DIR}/`)) return html; const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); const result = await materializePrivateAssetsInWorkspaceHtml({ pool: authPool, storageRoot, h5Root: __dirname, userId, html, htmlRelativePath, writeBack: true, }).catch(() => ({ html, changed: false })); return result.changed ? result.html : html; } async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) { if (!filePath.toLowerCase().endsWith('.html')) { res.sendFile(filePath, (err) => { if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' }); }); return; } if (await sendLongImageDownloadIfRequested(req, res, filePath)) return; let html; try { html = fs.readFileSync(filePath, 'utf8'); } catch { res.status(404).json({ message: '文件不存在' }); return; } html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html); html = rewriteKnownCdnScriptSources(html).html; html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET); const embed = isPlazaEmbedRequest(req.query); if (embed) { html = preparePublicationHtmlForEmbed(html); allowPlazaEmbedFrame(res); res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); } const context = buildPublishedHtmlViewContext({ origin: resolveRequestOrigin(req), requestPath: req.originalUrl || req.url || '', filePath, thumbnailPngPathForSvg, }); const parsedPublishPath = parseMindSpacePublishFilePath(filePath, __dirname); const pageOwner = parsedPublishPath?.userId && userAuth ? await userAuth.getUserById(parsedPublishPath.userId).catch(() => null) : null; let pageDataContext = null; if (mindSpacePages) { if (parsedPublishPath?.userId && parsedPublishPath.relativePath) { const page = await mindSpacePages .findPageByRelativePath(parsedPublishPath.userId, parsedPublishPath.relativePath) .catch(() => null); if (page?.id) { pageDataContext = { pageId: page.id, accessMode: page.publicationAccessMode ?? null, }; } } } html = injectMindSpaceAnalytics(html, { ownerId: parsedPublishPath?.userId ?? '', ownerSegment: resolveAnalyticsOwnerSegment(pageOwner ?? {}), ownerLabel: resolveAnalyticsOwnerLabel(pageOwner ?? {}), pageId: pageDataContext?.pageId ?? '', publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? '', config: mindSpaceAnalyticsConfig, }); const decorated = decorateMindSpacePublishedHtml({ html, embed, isOwner, pageDataContext, context, htmlFilePath: filePath, userAgent: req.get('user-agent') || '', preparePublicationHtmlForEmbed, injectOgTags, injectWechatShareBridge, injectPublicFileShareButton, publishedPageCsp, isWechatUserAgent, }); html = decorated.html; if (decorated.allowEmbedFrame) { allowPlazaEmbedFrame(res); } res.set('Content-Security-Policy', decorated.csp); res.set('Content-Type', 'text/html; charset=utf-8'); // REGRESSION GUARD: mindspace-public-owner-vs-visitor — this HTML now varies by viewer // (owner sees the Plaza entry, visitors don't), so it must never be cached/shared across // sessions. There is no CDN/proxy_cache in front of this route today; keep it that way. res.set('Cache-Control', 'private, no-store'); res.send(html); } async function serveUserPublishFile(req, res, next) { const result = await resolveMindSpacePublicRequest({ h5Root: __dirname, requestPath: req.path, resolveUsernameToUserId: async (username) => { if (!authPool) return null; const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [username]); return rows[0]?.id ? String(rows[0].id) : null; }, resolveClosestHtmlRelativePath, ensureThumbnail: async (publishDir, relativePath) => { await ensureWorkspaceHtmlThumbnail(publishDir, relativePath).catch(() => {}); }, logger: console, }); if (result.action === 'redirect') { res.redirect(result.status ?? 301, result.location); return; } if (result.action === 'forbidden') { res.status(403).json({ message: '禁止访问' }); return; } if (result.action === 'not_found') { if (result.reason === 'missing_owner_dir') { res.status(404).json({ message: '用户不存在' }); return; } if (result.reason === 'missing_directory_index') { res.status(404).json({ message: '目录中没有 index.html' }); return; } res.status(404).json({ message: '文件不存在' }); return; } // On-demand cover: rasterize .thumbnail.svg → .thumbnail.png the first time a // forwarded link's og:image is fetched (and refresh it when the SVG changes). const resolvedPath = result.filePath; if (authPool && result.ownerKey && /\.html$/i.test(resolvedPath)) { const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: result.ownerKey }); const relativePath = path.relative(publishDir, resolvedPath).replace(/\\/g, '/'); const contract = await getPageDeliveryContract({ pool: authPool, userId: result.ownerKey, relativePath, }).catch(() => null); if (contract && contract.status !== 'ready') { return res.status(409).type('text/plain; charset=utf-8').send('页面已生成,正在完成发布验证,请稍后重试。'); } } if (/\.thumbnail\.png$/i.test(resolvedPath)) { const svgSibling = resolvedPath.replace(/\.png$/i, '.svg'); if (fs.existsSync(svgSibling)) { const pngPath = ensureThumbnailPng(svgSibling); if (pngPath && fs.existsSync(pngPath)) { res.set('Cache-Control', 'public, max-age=300'); res.sendFile(pngPath); return; } } } // REGRESSION GUARD: mindspace-public-owner-vs-visitor — this workspace URL is the one the // agent hands back in chat and is reachable by anyone (no login required). Only the logged-in // author (viewer.id === ownerKey) may see the "发布 Plaza" entry; everyone else is a visitor. const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken).catch(() => null) : null; const isOwner = Boolean( viewer?.id && result.ownerKey && String(viewer.id).toLowerCase() === String(result.ownerKey).toLowerCase(), ); await sendPublishFile(req, res, resolvedPath, { isOwner }); } app.use('/temp', (req, res) => { res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`); }); // Serve user public pages: /user//path/to/file // These are public-facing pages (like travel guides) stored in user directories // Accessed via URL like https://goo.tkmind.cn/user/john/vietnam-guide/ app.use('/user', async (req, res, next) => { await userAuthReady; // Path format: /user// const parts = req.path.split('/').filter(Boolean); if (parts.length < 1) return next(); const [username, ...rest] = parts; const targetDir = path.join(USERS_ROOT, username); // Security: ensure resolved path is within the user's directory const filePath = path.join(targetDir, ...rest); const resolvedRoot = path.resolve(targetDir); const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) { return res.status(403).json({ message: '禁止访问' }); } // Check if the user directory exists if (!fs.existsSync(targetDir)) { return res.status(404).json({ message: '用户不存在' }); } if (!fs.existsSync(resolvedPath)) { return res.status(404).json({ message: '文件不存在' }); } // If it's a directory, serve index.html if (fs.statSync(resolvedPath).isDirectory()) { const indexPath = path.join(resolvedPath, 'index.html'); if (fs.existsSync(indexPath)) { return res.sendFile(indexPath); } return res.status(404).json({ message: '目录中没有 index.html' }); } res.sendFile(resolvedPath, (err) => { if (err) res.status(404).json({ message: '文件不存在' }); }); }); app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => { res.sendFile(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki', 'index.html')); }); app.get('/temp/wiki/*', (req, res) => { res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice('/temp/wiki'.length)}`); }); app.use( '/plaza-covers', express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }), ); app.use('/brand', express.static(path.join(__dirname, 'public/brand'), { maxAge: '7d' })); app.use('/assets', express.static(path.join(__dirname, 'public/assets'), { maxAge: '1d' })); app.get('/dev/wechat-share-preview', async (req, res) => { try { const rawTarget = String(req.query.url ?? req.query.path ?? '').trim(); if (!rawTarget) { return res.status(400).type('text/plain; charset=utf-8').send('缺少 url 参数,例如 ?url=/MindSpace/john/public/demo.html'); } const origin = resolveRequestOrigin(req) || `http://${HOST}:${PORT}`; const targetUrl = rawTarget.startsWith('http') ? new URL(rawTarget) : new URL(rawTarget.startsWith('/') ? rawTarget : `/${rawTarget}`, origin); const upstream = await fetch(targetUrl.toString(), { headers: { 'user-agent': req.get('user-agent') || 'tkmind-wechat-share-preview', accept: 'text/html,*/*', }, redirect: 'follow', }); if (!upstream.ok) { return res.status(upstream.status).type('text/plain; charset=utf-8').send(`页面请求失败:HTTP ${upstream.status}`); } let html = await upstream.text(); const pageUrl = targetUrl.toString().split('#')[0]; const pageDirUrl = `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}`; html = injectOgTags(html, { origin: targetUrl.origin, pageUrl, pageDirUrl }); const preview = extractSharePreviewMeta(html, { origin: targetUrl.origin, pageUrl, pageDirUrl, }); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'no-store'); return res.send( renderWechatSharePreviewHtml(preview, { note: '此预览读取页面最终 HTML 中的 Open Graph 标签,可用来对照微信里粘贴链接后的卡片效果。', }), ); } catch (err) { return res.status(500).type('text/plain; charset=utf-8').send(String(err?.message ?? err)); } }); app.use('/dev', express.static(path.join(__dirname, 'public/dev'), { maxAge: 0 })); app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => { const fileName = path.basename(req.path); const filePath = path.join(__dirname, 'public', fileName); if (!fs.existsSync(filePath)) return res.status(404).end(); res.type('text/plain').sendFile(filePath); }); app.use('/auth', (_req, res) => { res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); }); app.use('/admin-api', (_req, res) => { res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); }); app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' })); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); userAuthReady.then((enabled) => { if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === 'production') { console.error('Refusing to start portal without user auth while database is configured'); process.exit(1); } const server = app.listen(PORT, HOST, () => { console.log(`[Portal] Runtime profile: ${describeMemindRuntimeProfile()}`); console.log(`TKMind H5 @ http://${HOST}:${PORT}`); console.log(`Proxy -> ${API_TARGETS.join(', ')}`); console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`); console.log(`Wiki @ http://${HOST}:${PORT}/${PUBLISH_ROOT_DIR}/wiki`); }); server.on('error', (err) => { if (err?.code === 'EADDRINUSE') { console.error( `Port ${PORT} already in use (${HOST}:${PORT}); exiting so LaunchAgent can retry after ThrottleInterval`, ); process.exit(1); } throw err; }); });