Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+549
-28
@@ -29,7 +29,8 @@ import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
|
||||
import { attachRequestId, sendData, sendError } from './api-response.mjs';
|
||||
import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
|
||||
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
|
||||
import { createMindSpaceService } from './mindspace.mjs';
|
||||
import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
|
||||
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
|
||||
import { createAssetService } from './mindspace-assets.mjs';
|
||||
import { createPageService, pageInternals } from './mindspace-pages.mjs';
|
||||
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
|
||||
@@ -49,7 +50,9 @@ 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,
|
||||
publishedPageCspForEmbed,
|
||||
@@ -66,15 +69,21 @@ import {
|
||||
buildWorkspaceBaseHref,
|
||||
buildWorkspaceThumbnailUrl,
|
||||
injectHtmlBaseHref,
|
||||
resolveClosestHtmlRelativePath,
|
||||
resolveChatSaveAnalysis,
|
||||
resolveStaticHtmlContent,
|
||||
} from './mindspace-chat-save.mjs';
|
||||
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
|
||||
import { injectOgTags } from './mindspace-og-tags.mjs';
|
||||
import { ensureThumbnailPng, thumbnailPngPathForSvg } from './mindspace-thumbnail-png.mjs';
|
||||
import {
|
||||
ensureThumbnailPng,
|
||||
rasterizeThumbnailSvgToPng,
|
||||
thumbnailPngPathForSvg,
|
||||
} from './mindspace-thumbnail-png.mjs';
|
||||
import { scanContent } from './mindspace-content-scan.mjs';
|
||||
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
|
||||
import { createRechargeService } from './billing-recharge.mjs';
|
||||
import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs';
|
||||
import {
|
||||
createWechatPayClient,
|
||||
loadWechatPayConfig,
|
||||
@@ -85,6 +94,7 @@ import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
|
||||
import { createScheduleService } from './schedule-service.mjs';
|
||||
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
||||
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
||||
import { createSessionSnapshotService } from './session-snapshot.mjs';
|
||||
import { attachAsrRoutes } from './asr-proxy.mjs';
|
||||
import { isNativeH5ApiPath } from './policies.mjs';
|
||||
|
||||
@@ -174,7 +184,7 @@ const jsonUnlessMultipart = (req, res, next) => {
|
||||
};
|
||||
const rawUploadBody = express.raw({
|
||||
type: 'application/octet-stream',
|
||||
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
|
||||
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
});
|
||||
|
||||
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
|
||||
@@ -186,6 +196,7 @@ if (ACCESS_PASSWORD) {
|
||||
|
||||
let userAuth = null;
|
||||
let tkmindProxy = null;
|
||||
let sessionSnapshotService = null;
|
||||
let mindSpace = null;
|
||||
let mindSpaceAssets = null;
|
||||
let mindSpaceAudit = null;
|
||||
@@ -204,12 +215,14 @@ 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 scheduleService = null;
|
||||
let scheduleReminderWorker = null;
|
||||
let llmProviderService = null;
|
||||
let wordFilterService = null;
|
||||
let authPool = null;
|
||||
|
||||
async function bootstrapUserAuth() {
|
||||
@@ -218,17 +231,24 @@ async function bootstrapUserAuth() {
|
||||
const pool = createDbPool();
|
||||
authPool = pool;
|
||||
await initSchema(pool);
|
||||
await ensureMindSpaceConfig(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
scheduleService = createScheduleService(pool, {
|
||||
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||||
});
|
||||
mindSpace = createMindSpaceService(pool, {
|
||||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
|
||||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10),
|
||||
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
|
||||
monthlyViewLimit: Number(process.env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1000),
|
||||
scheduleService,
|
||||
});
|
||||
mindSpaceAssets = createAssetService(pool, {
|
||||
h5Root: __dirname,
|
||||
storageRoot:
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
|
||||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
});
|
||||
mindSpacePages = createPageService(pool, {
|
||||
h5Root: __dirname,
|
||||
@@ -297,13 +317,17 @@ async function bootstrapUserAuth() {
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||||
h5Root: __dirname,
|
||||
});
|
||||
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),
|
||||
});
|
||||
scheduleService = createScheduleService(pool, {
|
||||
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||||
subscriptionService,
|
||||
});
|
||||
wechatPayClient = createWechatPayClient(loadWechatPayConfig());
|
||||
wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth });
|
||||
@@ -321,7 +345,7 @@ async function bootstrapUserAuth() {
|
||||
pageService: mindSpacePages,
|
||||
storageRoot:
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||||
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
|
||||
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
});
|
||||
mindSpaceAgentRunner = createMindSpaceAgentRunner({
|
||||
apiTarget: API_TARGET,
|
||||
@@ -358,6 +382,7 @@ async function bootstrapUserAuth() {
|
||||
apiTarget: API_TARGET,
|
||||
apiSecret: API_SECRET,
|
||||
});
|
||||
wordFilterService = createWordFilterService(pool);
|
||||
void llmProviderService
|
||||
.ensureBootstrapRelay()
|
||||
.then((result) => {
|
||||
@@ -371,12 +396,21 @@ async function bootstrapUserAuth() {
|
||||
void llmProviderService.syncSelectedToGoosed().catch((err) => {
|
||||
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
||||
});
|
||||
sessionSnapshotService = createSessionSnapshotService(pool);
|
||||
tkmindProxy = createTkmindProxy({
|
||||
apiTarget: API_TARGET,
|
||||
apiTargets: API_TARGETS,
|
||||
apiSecret: API_SECRET,
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
subscriptionService,
|
||||
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,
|
||||
});
|
||||
wechatMpService = createWechatMpService({
|
||||
config: WECHAT_MP_CONFIG,
|
||||
@@ -387,6 +421,11 @@ async function bootstrapUserAuth() {
|
||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||
},
|
||||
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
||||
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
|
||||
});
|
||||
userAuth.setRechargeNotifier(async ({ userId, title, body }) => {
|
||||
if (!wechatMpService?.enabled) return;
|
||||
await wechatMpService.sendTextToUser(userId, `${title}\n${body}`.trim());
|
||||
});
|
||||
if (
|
||||
process.env.H5_REMINDER_WORKER_ENABLED === '1' &&
|
||||
@@ -399,6 +438,20 @@ async function bootstrapUserAuth() {
|
||||
});
|
||||
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,
|
||||
@@ -830,12 +883,13 @@ app.get('/auth/me', async (req, res) => {
|
||||
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 = await userAuth.listPathGrants(me.id);
|
||||
const capabilityState = await userAuth.resolveUserCapabilities(
|
||||
await userAuth.getUserById(me.id),
|
||||
);
|
||||
const [paths, capabilityState, subscription] = await Promise.all([
|
||||
userAuth.listPathGrants(me.id),
|
||||
userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)),
|
||||
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
|
||||
]);
|
||||
return res.json({
|
||||
user: me,
|
||||
user: { ...me, subscription },
|
||||
paths,
|
||||
capabilities: capabilityState.capabilities,
|
||||
grantedSkills: capabilityState.grantedSkills ?? [],
|
||||
@@ -867,6 +921,139 @@ app.get('/auth/usage', async (req, res) => {
|
||||
res.json({ records });
|
||||
});
|
||||
|
||||
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: '未启用用户系统' });
|
||||
@@ -888,8 +1075,133 @@ app.get('/auth/billing/config', async (req, res) => {
|
||||
}
|
||||
const me = await userAuth.getMe(userToken(req));
|
||||
if (!me) return res.status(401).json({ message: '未登录' });
|
||||
const config = await rechargeService.getBillingConfig(me.id);
|
||||
return res.json(config);
|
||||
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) => {
|
||||
@@ -980,18 +1292,21 @@ if (WECHAT_MP_CONFIG.enabled) {
|
||||
if (!wechatMpService?.enabled) {
|
||||
return res.status(503).send('wechat mp disabled');
|
||||
}
|
||||
if (!wechatMpService.verifyRequest(req.query)) {
|
||||
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(403).send('invalid signature');
|
||||
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(req.query.echostr ?? ''));
|
||||
return res.type('text/plain').send(String(result.body ?? ''));
|
||||
});
|
||||
|
||||
app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => {
|
||||
@@ -1125,6 +1440,7 @@ api.use(async (req, res, next) => {
|
||||
if (req.path === '/status') return next();
|
||||
if (req.path.startsWith('/internal/agent/')) return next();
|
||||
if (req.path === '/agent/mindspace_page_patch') return next();
|
||||
if (req.path === '/config/blocked-words') return next();
|
||||
|
||||
const plazaPublic = isPlazaPublicRead(req.path, req.method);
|
||||
|
||||
@@ -1149,6 +1465,13 @@ api.use(async (req, res, next) => {
|
||||
|
||||
attachAsrRoutes(api, { sendError, sendData });
|
||||
|
||||
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) {
|
||||
@@ -2466,6 +2789,30 @@ api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -2892,6 +3239,67 @@ api.get('/sessions', async (req, res, 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 userAuth.ownsSession(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()) {
|
||||
const snapshot = await sessionSnapshotService.get(sessionId);
|
||||
if (snapshot) {
|
||||
const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc;
|
||||
const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa;
|
||||
if (mcMatch && uaMatch) {
|
||||
// Cache hit — reconstruct a Goose-compatible session response.
|
||||
const cachedGooseSession = {
|
||||
...snapshot.session,
|
||||
// Embed only userVisible messages so getSession callers still work.
|
||||
conversation: snapshot.messages,
|
||||
};
|
||||
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);
|
||||
}
|
||||
const gooseSession = await upstream.json();
|
||||
// Write-through: persist snapshot async, don't block the response.
|
||||
if (sessionSnapshotService?.isEnabled()) {
|
||||
const messages = (gooseSession.conversation ?? [])
|
||||
.filter((m) => m.metadata?.userVisible);
|
||||
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();
|
||||
@@ -2910,6 +3318,8 @@ api.delete('/sessions/:sessionId', async (req, res, next) => {
|
||||
return res.status(upstream.status).send(text || '删除会话失败');
|
||||
}
|
||||
await userAuth.unregisterAgentSession(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 : '删除会话失败' });
|
||||
@@ -2924,7 +3334,15 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
if (!owns) {
|
||||
return res.status(403).json({ message: '无权访问该会话' });
|
||||
}
|
||||
return tkmindProxy.proxySessionEvents(req, res, sessionId);
|
||||
// After Finish, async-refresh snapshot so next open is a cache hit.
|
||||
const onAfterFinish = sessionSnapshotService?.isEnabled()
|
||||
? (sid, uid) =>
|
||||
sessionSnapshotService.refresh(sid, uid, async (pathname, init) => {
|
||||
const target = await tkmindProxy.resolveTarget(sid);
|
||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||
})
|
||||
: null;
|
||||
return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish });
|
||||
});
|
||||
|
||||
api.use(async (req, res, next) => {
|
||||
@@ -2956,6 +3374,20 @@ api.use(async (req, res, next) => {
|
||||
suggestedTiers: gate.suggestedTiers,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await tkmindProxy.reconcileSessionPolicyForUser(req.currentUser.id, sessionId);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Session policy sync before reply failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return res.status(500).json({
|
||||
message:
|
||||
err instanceof Error
|
||||
? `会话策略同步失败:${err.message}`
|
||||
: '会话策略同步失败',
|
||||
});
|
||||
}
|
||||
if (llmProviderService) {
|
||||
try {
|
||||
await tkmindProxy.applySessionLlmProvider(sessionId);
|
||||
@@ -3201,8 +3633,8 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
</head>
|
||||
<body>
|
||||
<div class="publication-shell">
|
||||
<!-- Game-like publications need script execution inside the shell iframe. -->
|
||||
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-same-origin allow-scripts"></iframe>
|
||||
<!-- Game-like publications need script execution, but not same-origin sandbox escape. -->
|
||||
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts"></iframe>
|
||||
</div>
|
||||
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
|
||||
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
|
||||
@@ -3367,12 +3799,17 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
});
|
||||
}
|
||||
function syncFrameHeight() {
|
||||
if (!frame || !frame.contentDocument || !frame.contentDocument.body) return;
|
||||
if (!frame) return;
|
||||
var doc = frame.contentDocument;
|
||||
var body = doc && doc.body;
|
||||
var root = doc && doc.documentElement;
|
||||
var height = Math.max(
|
||||
doc.documentElement ? doc.documentElement.scrollHeight : 0,
|
||||
doc.body.scrollHeight,
|
||||
window.innerHeight
|
||||
body ? body.scrollHeight : 0,
|
||||
body ? body.offsetHeight : 0,
|
||||
root ? root.scrollHeight : 0,
|
||||
root ? root.offsetHeight : 0,
|
||||
window.innerHeight,
|
||||
320
|
||||
);
|
||||
frame.style.height = height + 'px';
|
||||
}
|
||||
@@ -3384,7 +3821,9 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
}
|
||||
window.addEventListener('message', function (event) {
|
||||
if (!event || !event.data || event.data.type !== 'plaza:embed-section' || !frame) return;
|
||||
if (event.data.height) frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight) + 'px';
|
||||
if (event.data.height) {
|
||||
frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight, 320) + 'px';
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', syncFrameHeight);
|
||||
})();
|
||||
@@ -3397,6 +3836,7 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
|
||||
let html = result.html;
|
||||
if (embed) {
|
||||
html = preparePublicationHtmlForEmbed(html);
|
||||
allowPlazaEmbedFrame(res);
|
||||
} else if (raw) {
|
||||
html = stripPublicationHtmlCspMeta(html);
|
||||
}
|
||||
@@ -3578,6 +4018,36 @@ async function resolvePublishedRoute(req, res, password = null) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -3663,6 +4133,12 @@ function sendPublishFile(req, res, filePath) {
|
||||
res.status(404).json({ message: '文件不存在' });
|
||||
return;
|
||||
}
|
||||
const embed = isPlazaEmbedRequest(req.query);
|
||||
if (embed) {
|
||||
html = preparePublicationHtmlForEmbed(html);
|
||||
allowPlazaEmbedFrame(res);
|
||||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
|
||||
}
|
||||
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
|
||||
// Share cards (esp. WeChat) require https og:image. The edge only serves public domains
|
||||
// over https, but the proxy chain forwards X-Forwarded-Proto: http to the node — so for a
|
||||
@@ -3701,6 +4177,34 @@ function sendPublishFile(req, res, filePath) {
|
||||
res.send(html);
|
||||
}
|
||||
|
||||
const MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i;
|
||||
|
||||
async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) {
|
||||
if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null;
|
||||
const filename = rest[1];
|
||||
if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null;
|
||||
|
||||
const destination = path.resolve(targetDir, PUBLIC_ZONE_DIR, filename);
|
||||
if (!destination.startsWith(`${resolvedRoot}${path.sep}`)) return null;
|
||||
const candidates = [
|
||||
path.resolve(__dirname, filename),
|
||||
path.resolve(__dirname, PUBLIC_ZONE_DIR, filename),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === destination) continue;
|
||||
if (!candidate.startsWith(`${__dirname}${path.sep}`)) continue;
|
||||
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue;
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(candidate, destination);
|
||||
await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => {});
|
||||
console.warn(
|
||||
`[MindSpace] recovered misplaced public HTML ${path.relative(__dirname, candidate)} -> ${path.relative(__dirname, destination)}`,
|
||||
);
|
||||
return destination;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function serveUserPublishFile(req, res, next) {
|
||||
const parts = req.path.split('/').filter(Boolean);
|
||||
if (parts.length < 1) {
|
||||
@@ -3759,10 +4263,27 @@ async function serveUserPublishFile(req, res, next) {
|
||||
fs.existsSync(publicFallback) &&
|
||||
fs.statSync(publicFallback).isFile()
|
||||
) {
|
||||
sendPublishFile(req, res, publicFallback);
|
||||
const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${PUBLIC_ZONE_DIR}/${encodeURIComponent(rest[0])}`;
|
||||
res.redirect(301, canonical);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith('.html')) {
|
||||
const similarRelativePath = await resolveClosestHtmlRelativePath(targetDir, `${PUBLIC_ZONE_DIR}/${rest[1]}`);
|
||||
if (similarRelativePath) {
|
||||
const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${similarRelativePath
|
||||
.split('/')
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join('/')}`;
|
||||
res.redirect(301, canonical);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest);
|
||||
if (recoveredPublicHtml) {
|
||||
sendPublishFile(req, res, recoveredPublicHtml);
|
||||
return;
|
||||
}
|
||||
res.status(404).json({ message: '文件不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user