feat(feedback): 新增用户反馈提交、分页列表与语音描述

支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 20:49:15 +08:00
parent 08c5b22d4a
commit 18ea4f82fd
19 changed files with 2846 additions and 150 deletions
+281 -35
View File
@@ -77,6 +77,8 @@ import {
resolveChatSaveAnalysis,
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import { syncPublicHtmlAfterFinish } from './mindspace-public-finish-sync.mjs';
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import { injectOgTags } from './mindspace-og-tags.mjs';
import {
@@ -96,6 +98,7 @@ import {
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.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 { createSessionSnapshotService } from './session-snapshot.mjs';
@@ -202,16 +205,13 @@ const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
const rawUploadBodyImage = express.raw({
type: 'application/octet-stream',
limit: 2 * 1024 * 1024,
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
let legacyAuth = null;
if (ACCESS_PASSWORD) {
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;
@@ -241,6 +241,7 @@ let wechatPayClient = null;
let wechatOAuthService = null;
let wechatMpService = null;
let scheduleService = null;
let feedbackService = null;
let scheduleReminderWorker = null;
let llmProviderService = null;
let wordFilterService = null;
@@ -258,6 +259,7 @@ async function bootstrapUserAuth() {
scheduleService = createScheduleService(pool, {
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
feedbackService = createFeedbackService(pool);
mindSpace = createMindSpaceService(pool, {
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10),
@@ -605,6 +607,12 @@ async function bootstrapUserAuth() {
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;
}
}
@@ -661,6 +669,13 @@ app.get('/auth/status', async (req, res) => {
unrestricted: capabilityState.unrestricted,
});
}
if (isDatabaseConfigured()) {
return res.status(503).json({
authenticated: false,
mode: 'unavailable',
message: '用户认证服务不可用,请稍后重试',
});
}
if (legacyAuth) {
return res.json({
authenticated: legacyAuth.verify(legacySessionToken(req)),
@@ -1059,6 +1074,83 @@ app.get('/auth/usage', async (req, res) => {
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) {
@@ -1627,6 +1719,115 @@ api.get('/status', async (_req, res, next) => {
return next();
});
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) {
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 || '读取会话失败');
}
const session = await upstream.json();
return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible);
}
async function syncUserMemoriesIntoSession(userId, sessionId) {
if (!tkmindProxy || !sessionId) return false;
await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId);
return true;
}
api.post('/user-memory/v1/remember-recent', async (req, res) => {
if (!conversationMemoryService?.isEnabled?.()) {
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 userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const messages = await loadUserVisibleConversation(sessionId);
const result = await conversationMemoryService.saveAndAnalyze(
sessionId,
req.currentUser.id,
messages,
);
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { 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) => {
if (!conversationMemoryService?.isEnabled?.()) {
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 userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const result = await conversationMemoryService.analyzeUser(req.currentUser.id);
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { 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('/mindspace/v1/space', async (req, res) => {
if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return;
const space = await mindSpace.getSpace(req.currentUser.id);
@@ -2103,7 +2304,7 @@ api.post('/mindspace/v1/uploads', async (req, res) => {
}
});
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBodyImage, async (req, res) => {
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(
@@ -2426,25 +2627,23 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
return { session, message, content };
}
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'private', 'public']);
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) {
if (categoryCode !== 'private') return;
if (!privacyScan.allowed) {
throw Object.assign(new Error('内容含阻断级敏感信息,不能保存到私人区'), {
code: 'security_risk_blocked',
details: { findings: privacyScan.findings },
});
}
if (privacyScan.findings.length === 0) return;
const acknowledged = new Set((acknowledgedFindingIds ?? []).map(String));
const missing = privacyScan.findings.filter((finding) => !acknowledged.has(finding.id));
if (missing.length > 0) {
throw Object.assign(new Error('保存到私人区前需确认敏感信息提示'), {
code: 'private_ack_required',
details: { findings: missing },
});
}
async function syncUserGeneratedPages(userId) {
if (!mindSpacePages || !authPool || !userId) return;
await syncGeneratedPagesFromPublicAssets({
pool: authPool,
pageService: mindSpacePages,
assetService: mindSpaceAssets,
userId,
publishDir: resolvePublishDir(__dirname, { id: userId }),
syncWorkspaceAssets:
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
: null,
}).catch((error) => {
console.warn('[MindSpace] page sync failed:', error?.message ?? error);
});
}
async function resolveChatSaveBundle(user, h5Root, input = {}) {
@@ -2733,7 +2932,6 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
}
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids);
if (categoryCode !== 'draft') {
let buffer;
@@ -2847,6 +3045,7 @@ api.post('/mindspace/v1/pages', async (req, res) => {
api.get('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
await syncUserGeneratedPages(req.currentUser.id);
const pages = await mindSpacePages.listPages(req.currentUser.id, {
status: typeof req.query.status === 'string' ? req.query.status : undefined,
});
@@ -3777,14 +3976,42 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
// 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;
// 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) => {
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;
}
}
await syncPublicHtmlAfterFinish({
messages,
currentUser: req.currentUser,
publishDir: resolvePublishDir(__dirname, { id: uid }),
syncWorkspaceAssets:
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
: null,
});
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish });
});
@@ -4648,6 +4875,14 @@ async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) {
return null;
}
function decodePathSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
async function serveUserPublishFile(req, res, next) {
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) {
@@ -4668,7 +4903,7 @@ async function serveUserPublishFile(req, res, next) {
return;
}
const [username, ...rest] = [dirKey, ...parts.slice(1)];
const [username, ...rest] = [dirKey, ...parts.slice(1).map(decodePathSegment)];
const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username);
const resolvedRoot = path.resolve(targetDir);
const filePath = path.join(targetDir, ...rest);
@@ -4817,12 +5052,23 @@ app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
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);
}
app.listen(PORT, HOST, () => {
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);