diff --git a/.runtime/portal/RUNBOOK.txt b/.runtime/portal/RUNBOOK.txt new file mode 100644 index 0000000..7127d3d --- /dev/null +++ b/.runtime/portal/RUNBOOK.txt @@ -0,0 +1,25 @@ +Portal runtime artifact + +This directory is meant to run on production without the source tree. +Required persisted items to inherit on the host: + .env + MindSpace/ + data/ + users/ + .tailscale/ + public/plaza-covers/ + logs/ + +Bundled alongside server.mjs (required for sandbox-fs MCP): + mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps) + +Key runtime differences must stay in .env, not in the artifact: + DATABASE_URL / MYSQL_* + H5_PUBLIC_BASE_URL + TKMIND_API_TARGET / TKMIND_API_TARGET_1 + H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT + +Deployment and operations transport: + 105 fixed IP: 120.26.184.105 + 103 / Studio fixed IP: 58.38.22.103 + Do not switch back to 10.10.* LAN paths unless explicitly required. diff --git a/.runtime/portal/mindspace-sandbox-mcp.mjs b/.runtime/portal/mindspace-sandbox-mcp.mjs new file mode 100755 index 0000000..4a499ba --- /dev/null +++ b/.runtime/portal/mindspace-sandbox-mcp.mjs @@ -0,0 +1,1399 @@ +#!/usr/bin/env node +import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url); + +// mindspace-sandbox-mcp.mjs +import path from "node:path"; +import fs from "node:fs"; +import readline from "node:readline"; +import { execFileSync } from "node:child_process"; +import mysql from "mysql2/promise"; + +// schedule-service.mjs +import crypto from "node:crypto"; + +// schedule-time.mjs +var DEFAULT_TIMEZONE = "Asia/Shanghai"; +function pad2(value) { + return String(value).padStart(2, "0"); +} +function normalizeTimezone(timezone) { + return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE; +} +function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: normalizeTimezone(timezone), + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(epochMs)).map((part) => [part.type, part.value]) + ); + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour === "24" ? 0 : parts.hour), + minute: Number(parts.minute), + second: Number(parts.second) + }; +} +function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + return `${parts.month}\u6708${parts.day}\u65E5`; +} +function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + return zonedTimeToEpochMs( + { + year: parts.year, + month: parts.month, + day: parts.day, + hour: 0, + minute: 0, + second: 0 + }, + timezone + ); +} +function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + const utc = Date.UTC(parts.year, parts.month - 1, parts.day + Number(days || 0), parts.hour, parts.minute, parts.second); + const shifted = new Date(utc); + return zonedTimeToEpochMs( + { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth() + 1, + day: shifted.getUTCDate(), + hour: shifted.getUTCHours(), + minute: shifted.getUTCMinutes(), + second: shifted.getUTCSeconds() + }, + timezone + ); +} +function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) { + const tz = normalizeTimezone(timezone); + let guess = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour ?? 0), + Number(parts.minute ?? 0), + Number(parts.second ?? 0), + 0 + ); + for (let i = 0; i < 3; i += 1) { + const actual = getLocalParts(guess, tz); + const actualAsUtc = Date.UTC( + actual.year, + actual.month - 1, + actual.day, + actual.hour, + actual.minute, + actual.second, + 0 + ); + const desiredAsUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour ?? 0), + Number(parts.minute ?? 0), + Number(parts.second ?? 0), + 0 + ); + const delta = actualAsUtc - desiredAsUtc; + if (delta === 0) break; + guess -= delta; + } + return guess; +} +function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) { + const tz = normalizeTimezone(timezone); + const todayStart = startOfLocalDay(now, tz); + const todayParts = getLocalParts(todayStart, tz); + let runAt = zonedTimeToEpochMs( + { + year: todayParts.year, + month: todayParts.month, + day: todayParts.day, + hour: Number(hour), + minute: Number(minute), + second: 0 + }, + tz + ); + if (runAt <= now) { + const tomorrowStart = addLocalDays(todayStart, 1, tz); + const tomorrowParts = getLocalParts(tomorrowStart, tz); + runAt = zonedTimeToEpochMs( + { + year: tomorrowParts.year, + month: tomorrowParts.month, + day: tomorrowParts.day, + hour: Number(hour), + minute: Number(minute), + second: 0 + }, + tz + ); + } + return runAt; +} +function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + return `${pad2(parts.hour)}:${pad2(parts.minute)}`; +} + +// schedule-service.mjs +var DEFAULT_TIMEZONE2 = "Asia/Shanghai"; +function nowMs() { + return Date.now(); +} +function rowToItem(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + kind: row.kind, + title: row.title, + description: row.description ?? null, + status: row.status, + startAt: row.start_at == null ? null : Number(row.start_at), + endAt: row.end_at == null ? null : Number(row.end_at), + dueAt: row.due_at == null ? null : Number(row.due_at), + allDay: Boolean(row.all_day), + timezone: row.timezone || DEFAULT_TIMEZONE2, + location: row.location ?? null, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function rowToDigest(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + hour: Number(row.hour), + minute: Number(row.minute), + timezone: row.timezone || DEFAULT_TIMEZONE2, + channel: row.channel || "wechat", + status: row.status, + nextRunAt: Number(row.next_run_at), + lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at), + attempts: Number(row.attempts ?? 0) + }; +} +function rowToBalanceAlert(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + thresholdCents: Number(row.threshold_cents), + channel: row.channel || "wechat", + status: row.status, + nextRunAt: Number(row.next_run_at), + lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at), + lastNotifiedBalanceCents: row.last_notified_balance_cents == null ? null : Number(row.last_notified_balance_cents), + attempts: Number(row.attempts ?? 0), + lastError: row.last_error ?? null, + lockedUntil: row.locked_until == null ? null : Number(row.locked_until), + sourceChannel: row.source_channel ?? null, + sourceSessionId: row.source_session_id ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null + }; +} +function rowToReminder(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + itemId: row.item_id, + remindAt: Number(row.remind_at), + offsetMinutes: row.offset_minutes == null ? null : Number(row.offset_minutes), + channel: row.channel || "wechat", + status: row.status, + attempts: Number(row.attempts ?? 0), + lastError: row.last_error ?? null, + lockedUntil: row.locked_until == null ? null : Number(row.locked_until), + sentAt: row.sent_at == null ? null : Number(row.sent_at), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function parseJsonColumn(value) { + if (value == null || value === "") return null; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return null; + } + } + if (typeof value === "object") return value; + return null; +} +function rowToUserNotification(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + channel: row.channel, + notificationType: row.notification_type, + title: row.title, + body: row.body, + data: parseJsonColumn(row.data_json), + status: row.status, + readAt: row.read_at == null ? null : Number(row.read_at), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE2 } = {}) { + const date = localDateLabel(now, timezone); + if (!items.length) { + return `${date} \u5F85\u529E\u8BB0\u5F55 + +\u4ECA\u5929\u6682\u65F6\u6CA1\u6709\u5F85\u529E\u3002`; + } + const lines = [`${date} \u5F85\u529E\u8BB0\u5F55`, ""]; + items.forEach((item, index) => { + const time = item.dueAt || item.startAt ? `\uFF08${formatLocalTime(item.dueAt || item.startAt, timezone)}\uFF09` : ""; + lines.push(`${index + 1}. ${item.title}${time}`); + }); + return lines.join("\n"); +} +function createScheduleService(pool, options = {}) { + const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE); + const clock = options.clock || { now: nowMs }; + const createItem = async ({ + userId, + kind = "task", + title, + description = null, + startAt = null, + endAt = null, + dueAt = null, + allDay = false, + timezone = defaultTimezone, + location = null, + sourceChannel = "agent", + sourceSessionId = null, + sourceMessageId = null, + sourceText = null, + metadata = null + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const cleanTitle = String(title ?? "").trim(); + if (!cleanTitle) throw new Error("\u7F3A\u5C11\u5F85\u529E\u6807\u9898"); + const safeKind = kind === "event" ? "event" : "task"; + const id = crypto.randomUUID(); + const ts = clock.now(); + await pool.query( + `INSERT INTO h5_schedule_items + (id, user_id, kind, title, description, status, start_at, end_at, due_at, all_day, + timezone, location, source_channel, source_session_id, source_message_id, source_text, + metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + userId, + safeKind, + cleanTitle, + description, + startAt, + endAt, + dueAt, + allDay ? 1 : 0, + normalizeTimezone(timezone), + location, + sourceChannel, + sourceSessionId, + sourceMessageId, + sourceText, + metadata ? JSON.stringify(metadata) : null, + ts, + ts + ] + ); + return { + id, + userId, + kind: safeKind, + title: cleanTitle, + startAt, + endAt, + dueAt, + allDay: Boolean(allDay), + timezone: normalizeTimezone(timezone), + status: "active", + createdAt: ts, + updatedAt: ts + }; + }; + const listItems = async ({ userId, from = null, to = null, status = "active", limit = 100 } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?", "deleted_at IS NULL"]; + const params = [userId]; + if (status) { + clauses.push("status = ?"); + params.push(status); + } + if (from != null && to != null) { + clauses.push( + `( + (start_at IS NOT NULL AND start_at >= ? AND start_at < ?) + OR (due_at IS NOT NULL AND due_at >= ? AND due_at < ?) + OR (start_at IS NULL AND due_at IS NULL) + )` + ); + params.push(from, to, from, to); + } + params.push(Math.max(1, Math.min(500, Number(limit) || 100))); + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_items + WHERE ${clauses.join(" AND ")} + ORDER BY COALESCE(start_at, due_at, 9223372036854775807), created_at + LIMIT ?`, + params + ); + return rows.map(rowToItem); + }; + const listItemsBySourceMessage = async ({ userId, sourceMessageId, limit = 20 } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const cleanSourceMessageId = String(sourceMessageId ?? "").trim(); + if (!cleanSourceMessageId) return []; + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_items + WHERE user_id = ? + AND source_message_id = ? + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT ?`, + [userId, cleanSourceMessageId, Math.max(1, Math.min(100, Number(limit) || 20))] + ); + return rows.map(rowToItem); + }; + const listTodayTodoItems = async ({ userId, timezone = defaultTimezone, now = clock.now() } = {}) => { + const start = startOfLocalDay(now, timezone); + const end = addLocalDays(start, 1, timezone); + return listItems({ userId, from: start, to: end, status: "active", limit: 200 }); + }; + const createReminder = async ({ + userId, + itemId, + remindAt, + offsetMinutes = null, + channel = "wechat" + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + if (!itemId) throw new Error("\u7F3A\u5C11\u4E8B\u9879"); + const safeRemindAt = Number(remindAt); + if (!Number.isFinite(safeRemindAt) || safeRemindAt <= 0) { + throw new Error("\u63D0\u9192\u65F6\u95F4\u65E0\u6548"); + } + const safeOffsetMinutes = offsetMinutes == null || offsetMinutes === "" ? null : Number(offsetMinutes); + if (safeOffsetMinutes != null && !Number.isInteger(safeOffsetMinutes)) { + throw new Error("\u63D0\u9192\u504F\u79FB\u5206\u949F\u65E0\u6548"); + } + const safeChannel = channel === "in_app" ? "in_app" : "wechat"; + const [itemRows] = await pool.query( + `SELECT id + FROM h5_schedule_items + WHERE id = ? AND user_id = ? AND deleted_at IS NULL + LIMIT 1`, + [itemId, userId] + ); + if (!itemRows[0]) throw new Error("\u4E8B\u9879\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE"); + const id = crypto.randomUUID(); + const ts = clock.now(); + await pool.query( + `INSERT INTO h5_schedule_reminders + (id, user_id, item_id, remind_at, offset_minutes, channel, status, attempts, + last_error, locked_until, sent_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, NULL, ?, ?) + ON DUPLICATE KEY UPDATE + offset_minutes = VALUES(offset_minutes), + status = 'pending', + last_error = NULL, + locked_until = NULL, + updated_at = VALUES(updated_at)`, + [id, userId, itemId, safeRemindAt, safeOffsetMinutes, safeChannel, ts, ts] + ); + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_reminders + WHERE item_id = ? AND remind_at = ? AND channel = ? + LIMIT 1`, + [itemId, safeRemindAt, safeChannel] + ); + return rowToReminder(rows[0]) || { + id, + userId, + itemId, + remindAt: safeRemindAt, + offsetMinutes: safeOffsetMinutes, + channel: safeChannel, + status: "pending", + attempts: 0, + lastError: null, + lockedUntil: null, + sentAt: null, + createdAt: ts, + updatedAt: ts + }; + }; + const listDigestSubscriptions = async ({ + userId, + digestType = "todo_day", + channel = null, + status = null, + limit = 20 + } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?", "digest_type = ?"]; + const params = [userId, digestType]; + if (channel) { + clauses.push("channel = ?"); + params.push(channel); + } + if (status) { + const statuses = Array.isArray(status) ? status : [status]; + clauses.push(`status IN (${statuses.map(() => "?").join(", ")})`); + params.push(...statuses); + } + params.push(Math.max(1, Math.min(200, Number(limit) || 20))); + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_digest_subscriptions + WHERE ${clauses.join(" AND ")} + ORDER BY next_run_at ASC, created_at DESC + LIMIT ?`, + params + ); + return rows.map(rowToDigest); + }; + const createDailyTodoDigest = async ({ + userId, + hour, + minute = 0, + timezone = defaultTimezone, + channel = "wechat", + sourceChannel = "agent", + sourceSessionId = null, + sourceMessageId = null, + sourceText = null + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const safeHour = Number(hour); + const safeMinute = Number(minute); + if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) { + throw new Error("\u63D0\u9192\u5C0F\u65F6\u65E0\u6548"); + } + if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) { + throw new Error("\u63D0\u9192\u5206\u949F\u65E0\u6548"); + } + const tz = normalizeTimezone(timezone); + const nextRunAt = nextDailyRunAt({ + hour: safeHour, + minute: safeMinute, + timezone: tz, + now: clock.now() + }); + const id = crypto.randomUUID(); + const ts = clock.now(); + await pool.query( + `INSERT INTO h5_schedule_digest_subscriptions + (id, user_id, digest_type, hour, minute, timezone, channel, status, next_run_at, + source_channel, source_session_id, source_message_id, source_text, created_at, updated_at) + VALUES (?, ?, 'todo_day', ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + hour = VALUES(hour), + minute = VALUES(minute), + timezone = VALUES(timezone), + channel = VALUES(channel), + status = 'active', + next_run_at = VALUES(next_run_at), + source_channel = VALUES(source_channel), + source_session_id = VALUES(source_session_id), + source_message_id = VALUES(source_message_id), + source_text = VALUES(source_text), + updated_at = VALUES(updated_at)`, + [ + id, + userId, + safeHour, + safeMinute, + tz, + channel, + nextRunAt, + sourceChannel, + sourceSessionId, + sourceMessageId, + sourceText, + ts, + ts + ] + ); + const [rows] = await pool.query( + `SELECT * FROM h5_schedule_digest_subscriptions + WHERE user_id = ? AND digest_type = 'todo_day' AND channel = ? + LIMIT 1`, + [userId, channel] + ); + return rowToDigest(rows[0]) || { + id, + userId, + hour: safeHour, + minute: safeMinute, + timezone: tz, + channel, + status: "active", + nextRunAt, + lastRunAt: null, + attempts: 0 + }; + }; + const createBalanceLowAlert = async ({ + userId, + thresholdCents, + channel = "wechat", + sourceChannel = "agent", + sourceSessionId = null, + sourceMessageId = null, + sourceText = null + } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const safeThreshold = Number(thresholdCents); + if (!Number.isFinite(safeThreshold) || safeThreshold < 0) { + throw new Error("\u4F59\u989D\u9608\u503C\u65E0\u6548"); + } + const now = clock.now(); + const id = crypto.randomUUID(); + await pool.query( + `INSERT INTO h5_balance_alert_subscriptions + (id, user_id, threshold_cents, channel, status, next_run_at, last_run_at, + last_notified_balance_cents, attempts, locked_until, last_error, + source_channel, source_session_id, source_message_id, source_text, created_at, updated_at) + VALUES (?, ?, ?, ?, 'active', ?, NULL, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + threshold_cents = VALUES(threshold_cents), + channel = VALUES(channel), + status = 'active', + next_run_at = VALUES(next_run_at), + last_error = NULL, + locked_until = NULL, + updated_at = VALUES(updated_at)`, + [id, userId, safeThreshold, channel, now, sourceChannel, sourceSessionId, sourceMessageId, sourceText, now, now] + ); + const [rows] = await pool.query( + `SELECT * FROM h5_balance_alert_subscriptions WHERE user_id = ? AND channel = ? LIMIT 1`, + [userId, channel] + ); + return rowToBalanceAlert(rows[0]) || { + id, + userId, + thresholdCents: safeThreshold, + channel, + status: "active", + nextRunAt: now, + lastRunAt: null, + lastNotifiedBalanceCents: null, + attempts: 0 + }; + }; + const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => { + const [rows] = await pool.query( + `SELECT * + FROM h5_balance_alert_subscriptions + WHERE next_run_at <= ? + AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?)) + ORDER BY next_run_at ASC + LIMIT ?`, + [now, now, Math.max(1, Math.min(200, Number(limit) || 50))] + ); + return rows.map(rowToBalanceAlert); + }; + const lockBalanceAlert = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { + const lockedUntil = now + lockMs; + const [result] = await pool.query( + `UPDATE h5_balance_alert_subscriptions + SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? + WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`, + [lockedUntil, now, id, now] + ); + if (Number(result?.affectedRows ?? 0) !== 1) return null; + const [rows] = await pool.query( + `SELECT * FROM h5_balance_alert_subscriptions WHERE id = ? LIMIT 1`, + [id] + ); + return rowToBalanceAlert(rows[0]); + }; + const markBalanceAlertSent = async (subscription, { now = clock.now() } = {}) => { + await pool.query( + `UPDATE h5_balance_alert_subscriptions + SET status = 'active', next_run_at = ?, last_run_at = ?, last_notified_balance_cents = ?, + locked_until = NULL, last_error = NULL, updated_at = ? + WHERE id = ?`, + [now + 5 * 60 * 1e3, now, subscription.lastNotifiedBalanceCents ?? null, now, subscription.id] + ); + return { ...subscription, status: "active", lastRunAt: now }; + }; + const markBalanceAlertFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => { + const attempts = Number(subscription.attempts ?? 0); + const status = attempts >= maxAttempts ? "failed" : "active"; + const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt; + await pool.query( + `UPDATE h5_balance_alert_subscriptions + SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ? + WHERE id = ?`, + [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id] + ); + }; + const listDueDigestSubscriptions = async ({ now = clock.now(), limit = 50 } = {}) => { + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_digest_subscriptions + WHERE next_run_at <= ? + AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?)) + ORDER BY next_run_at ASC + LIMIT ?`, + [now, now, Math.max(1, Math.min(200, Number(limit) || 50))] + ); + return rows.map(rowToDigest); + }; + const lockDigestSubscription = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { + const lockedUntil = now + lockMs; + const [result] = await pool.query( + `UPDATE h5_schedule_digest_subscriptions + SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? + WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`, + [lockedUntil, now, id, now] + ); + if (Number(result?.affectedRows ?? 0) !== 1) return null; + const [rows] = await pool.query( + `SELECT * FROM h5_schedule_digest_subscriptions WHERE id = ? LIMIT 1`, + [id] + ); + return rowToDigest(rows[0]); + }; + const markDigestSent = async (subscription, { now = clock.now() } = {}) => { + const nextRunAt = nextDailyRunAt({ + hour: subscription.hour, + minute: subscription.minute, + timezone: subscription.timezone, + now: now + 1e3 + }); + await pool.query( + `UPDATE h5_schedule_digest_subscriptions + SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL, + last_error = NULL, updated_at = ? + WHERE id = ?`, + [nextRunAt, now, now, subscription.id] + ); + return { ...subscription, status: "active", nextRunAt, lastRunAt: now }; + }; + const markDigestFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => { + const attempts = Number(subscription.attempts ?? 0); + const status = attempts >= maxAttempts ? "failed" : "active"; + const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt; + await pool.query( + `UPDATE h5_schedule_digest_subscriptions + SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ? + WHERE id = ?`, + [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id] + ); + }; + const logDelivery = async ({ subscriptionId, userId, channel = "wechat", status, providerMessageId = null, errorCode = null, errorMessage = null }) => { + await pool.query( + `INSERT INTO h5_schedule_delivery_logs + (id, reminder_id, subscription_id, user_id, channel, status, provider_message_id, + error_code, error_message, created_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + crypto.randomUUID(), + subscriptionId, + userId, + channel, + status, + providerMessageId, + errorCode, + errorMessage ? String(errorMessage).slice(0, 500) : null, + clock.now() + ] + ); + }; + const buildTodoDigestText = async ({ userId, timezone = defaultTimezone, now = clock.now() }) => { + const items = await listTodayTodoItems({ userId, timezone, now }); + return formatTodoDigest(items, { now, timezone }); + }; + const getUserWalletSnapshot = async (userId) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const [rows] = await pool.query( + `SELECT balance_cents AS balanceCents, tokens_used AS tokensUsed + FROM h5_user_wallets + WHERE user_id = ? + LIMIT 1`, + [userId] + ); + return rows[0] ?? null; + }; + const createUserNotification = async ({ + userId, + channel = "web", + notificationType, + title, + body, + data = null + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + if (!notificationType) throw new Error("\u7F3A\u5C11\u901A\u77E5\u7C7B\u578B"); + if (!title) throw new Error("\u7F3A\u5C11\u901A\u77E5\u6807\u9898"); + if (!body) throw new Error("\u7F3A\u5C11\u901A\u77E5\u5185\u5BB9"); + const now = clock.now(); + const id = crypto.randomUUID(); + await pool.query( + `INSERT INTO h5_user_notifications + (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', NULL, ?, ?)`, + [id, userId, channel, notificationType, title, body, data ? JSON.stringify(data) : null, now, now] + ); + return { id, userId, channel, notificationType, title, body, data, status: "unread", readAt: null, createdAt: now, updatedAt: now }; + }; + const listUserNotifications = async ({ userId, status = "unread", limit = 20 } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?"]; + const params = [userId]; + if (status && status !== "all") { + clauses.push("status = ?"); + params.push(status); + } + params.push(Math.max(1, Math.min(100, Number(limit) || 20))); + const [rows] = await pool.query( + `SELECT * + FROM h5_user_notifications + WHERE ${clauses.join(" AND ")} + ORDER BY created_at DESC + LIMIT ?`, + params + ); + return rows.map(rowToUserNotification); + }; + const markUserNotificationRead = async ({ userId, notificationId }) => { + if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570"); + const now = clock.now(); + const [result] = await pool.query( + `UPDATE h5_user_notifications + SET status = 'read', read_at = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND status <> 'read'`, + [now, now, notificationId, userId] + ); + return Number(result?.affectedRows ?? 0) > 0; + }; + const markAllUserNotificationsRead = async ({ userId } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const now = clock.now(); + const [result] = await pool.query( + `UPDATE h5_user_notifications + SET status = 'read', read_at = ?, updated_at = ? + WHERE user_id = ? AND status <> 'read'`, + [now, now, userId] + ); + return Number(result?.affectedRows ?? 0); + }; + const deleteUserNotification = async ({ userId, notificationId } = {}) => { + if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570"); + const [result] = await pool.query( + `DELETE FROM h5_user_notifications + WHERE id = ? AND user_id = ?`, + [notificationId, userId] + ); + return Number(result?.affectedRows ?? 0) > 0; + }; + const clearUserNotifications = async ({ userId, status = "all" } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?"]; + const params = [userId]; + if (status && status !== "all") { + clauses.push("status = ?"); + params.push(status); + } + const [result] = await pool.query( + `DELETE FROM h5_user_notifications + WHERE ${clauses.join(" AND ")}`, + params + ); + return Number(result?.affectedRows ?? 0); + }; + return { + createItem, + createReminder, + listItems, + listItemsBySourceMessage, + listTodayTodoItems, + listDigestSubscriptions, + createDailyTodoDigest, + createBalanceLowAlert, + listDueDigestSubscriptions, + lockDigestSubscription, + markDigestSent, + markDigestFailed, + listDueBalanceAlerts, + lockBalanceAlert, + markBalanceAlertSent, + markBalanceAlertFailed, + logDelivery, + buildTodoDigestText, + getUserWalletSnapshot, + createUserNotification, + listUserNotifications, + markUserNotificationRead, + markAllUserNotificationsRead, + deleteUserNotification, + clearUserNotifications + }; +} + +// mindspace-sandbox-mcp.mjs +var SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim(); +if (!SANDBOX_ROOT) { + process.stderr.write("[mindspace-sandbox-mcp] SANDBOX_ROOT is not set \u2014 refusing to start\n"); + process.exit(1); +} +var SANDBOX = path.resolve(SANDBOX_ROOT); +var PRIVATE_DATA_DIR = path.join(SANDBOX, ".mindspace"); +var PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, "private-data.sqlite"); +var SQLITE_BIN = process.env.SQLITE_BIN?.trim() || "sqlite3"; +var PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024); +var PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5e3); +var PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200); +var PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim(); +var allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim(); +var ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(",").map((s) => s.trim())) : null; +function resolveSandboxed(p) { + if (!p || typeof p !== "string") throw new Error("\u8DEF\u5F84\u53C2\u6570\u65E0\u6548"); + const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(SANDBOX, p); + if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) { + throw Object.assign(new Error(`\u8DEF\u5F84\u8D8A\u754C\uFF1A${p} \u4E0D\u5728\u5F53\u524D\u5DE5\u4F5C\u533A\u5185`), { code: "EACCES" }); + } + return resolved; +} +var ALL_TOOLS = [ + { + name: "read_file", + description: "\u8BFB\u53D6\u6587\u4EF6\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF09", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84\uFF08\u76F8\u5BF9\u5DE5\u4F5C\u533A\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF09" } + }, + required: ["path"] + } + }, + { + name: "write_file", + description: "\u5199\u5165\u6587\u4EF6\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF1B\u4E0D\u5B58\u5728\u5219\u521B\u5EFA\uFF0C\u5DF2\u5B58\u5728\u5219\u8986\u76D6\uFF09", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84" }, + content: { type: "string", description: "\u6587\u4EF6\u5185\u5BB9" } + }, + required: ["path", "content"] + } + }, + { + name: "edit_file", + description: "\u5C06\u6587\u4EF6\u4E2D\u7684\u65E7\u5185\u5BB9\u66FF\u6362\u4E3A\u65B0\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF09", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84" }, + old_str: { type: "string", description: "\u8981\u66FF\u6362\u7684\u539F\u59CB\u5185\u5BB9\uFF08\u5FC5\u987B\u5728\u6587\u4EF6\u4E2D\u552F\u4E00\uFF09" }, + new_str: { type: "string", description: "\u66FF\u6362\u540E\u7684\u65B0\u5185\u5BB9" } + }, + required: ["path", "old_str", "new_str"] + } + }, + { + name: "list_dir", + description: "\u5217\u51FA\u76EE\u5F55\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u76EE\u5F55\uFF09", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "\u76EE\u5F55\u8DEF\u5F84\uFF08\u7701\u7565\u5219\u5217\u51FA\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55\uFF09" + } + } + } + }, + { + name: "create_dir", + description: "\u521B\u5EFA\u76EE\u5F55\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\uFF1B\u5DF2\u5B58\u5728\u4E0D\u62A5\u9519\uFF09", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "\u76EE\u5F55\u8DEF\u5F84" } + }, + required: ["path"] + } + }, + { + name: "private_data_info", + description: "\u67E5\u770B\u5F53\u524D\u7528\u6237\u201C\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u201D\u7684\u72B6\u6001\u3002\u6BCF\u4E2A\u7528\u6237\u53EA\u6709\u4E00\u4E2A\u79C1\u6709 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u4FDD\u5B58\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u521B\u5EFA\u989D\u5916 SQLite \u6587\u4EF6\u3002", + inputSchema: { type: "object", properties: {} } + }, + { + name: "private_data_schema", + description: "\u67E5\u770B\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u4E2D\u7684\u8868\u548C\u5B57\u6BB5\u3002\u7528\u4E8E\u4E86\u89E3\u5F53\u524D\u7528\u6237\u81EA\u5DF1\u7684 SQLite \u8868\u7ED3\u6784\u3002", + inputSchema: { type: "object", properties: {} } + }, + { + name: "private_data_query", + description: "\u53EA\u8BFB\u67E5\u8BE2\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u4EC5\u5141\u8BB8 SELECT/WITH\uFF0C\u9ED8\u8BA4\u6700\u591A\u8FD4\u56DE 200 \u884C\uFF0C\u9002\u5408\u7EDF\u8BA1\u95EE\u5377\u56DE\u7B54\u3001\u7B5B\u9009\u8868\u5355\u6570\u636E\u3001\u5206\u6790\u7528\u6237\u79C1\u6709\u6570\u636E\u3002", + inputSchema: { + type: "object", + properties: { + sql: { type: "string", description: "\u53EA\u8BFB SQL\uFF0C\u5FC5\u987B\u662F SELECT/WITH" } + }, + required: ["sql"] + } + }, + { + name: "private_data_execute", + description: "\u5199\u5165\u6216\u53D8\u66F4\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u53EF\u7528\u4E8E\u521B\u5EFA\u95EE\u5377/\u8868\u5355/\u6E05\u5355\u7B49\u79C1\u6709\u8868\u5E76\u5199\u5165\u6570\u636E\uFF1B\u7981\u6B62 ATTACH\u3001load_extension\u3001PRAGMA writable_schema\u3001VACUUM INTO \u7B49\u8D8A\u754C\u6216\u5371\u9669\u64CD\u4F5C\u3002", + inputSchema: { + type: "object", + properties: { + sql: { type: "string", description: "\u8981\u6267\u884C\u7684 SQLite SQL" } + }, + required: ["sql"] + } + } +]; +var quotaPool = null; +var scheduleService = null; +function isQuotaSyncConfigured() { + return Boolean( + PRIVATE_DATA_USER_ID && (process.env.DATABASE_URL || process.env.MYSQL_HOST && process.env.MYSQL_DATABASE) + ); +} +function getQuotaPool() { + if (!isQuotaSyncConfigured()) return null; + if (quotaPool) return quotaPool; + quotaPool = process.env.DATABASE_URL ? mysql.createPool(process.env.DATABASE_URL) : mysql.createPool({ + host: process.env.MYSQL_HOST ?? "localhost", + port: Number(process.env.MYSQL_PORT ?? 3306), + user: process.env.MYSQL_USER ?? "boot", + password: process.env.MYSQL_PASSWORD ?? "", + database: process.env.MYSQL_DATABASE ?? "tkmind", + waitForConnections: true, + connectionLimit: 2 + }); + return quotaPool; +} +function isScheduleConfigured() { + return isQuotaSyncConfigured(); +} +function getScheduleService() { + if (!PRIVATE_DATA_USER_ID) throw new Error("\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u7528\u6237\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u80FD\u64CD\u4F5C\u5F85\u529E"); + const pool = getQuotaPool(); + if (!pool || !isScheduleConfigured()) { + throw new Error("\u5F53\u524D\u73AF\u5883\u672A\u914D\u7F6E\u5F85\u529E\u6570\u636E\u5E93\u8FDE\u63A5"); + } + if (!scheduleService) { + scheduleService = createScheduleService(pool, { + defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai" + }); + } + return scheduleService; +} +if (isScheduleConfigured()) { + ALL_TOOLS.push( + { + name: "schedule_create_item", + description: "\u4E3A\u5F53\u524D\u7528\u6237\u521B\u5EFA\u5F85\u529E\u6216\u65E5\u7A0B\u4E8B\u9879\u3002\u4EC5\u5199\u5165\u5F53\u524D\u7528\u6237\u81EA\u5DF1\u7684 h5_schedule_items\uFF0C\u4E0D\u53EF\u64CD\u4F5C\u5176\u4ED6\u7528\u6237\u6570\u636E\u3002", + inputSchema: { + type: "object", + properties: { + kind: { type: "string", description: "task \u6216 event\uFF0C\u9ED8\u8BA4 task" }, + title: { type: "string", description: "\u4E8B\u9879\u6807\u9898" }, + description: { type: "string", description: "\u4E8B\u9879\u63CF\u8FF0\uFF0C\u53EF\u9009" }, + startAt: { type: "number", description: "\u5F00\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, + endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, + dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, + allDay: { type: "boolean", description: "\u662F\u5426\u5168\u5929\u4E8B\u9879\uFF0C\u53EF\u9009" }, + timezone: { type: "string", description: "\u65F6\u533A\uFF0C\u53EF\u9009" }, + location: { type: "string", description: "\u5730\u70B9\uFF0C\u53EF\u9009" }, + sourceMessageId: { type: "string", description: "\u670D\u52A1\u53F7\u6D88\u606F ID\uFF1B\u6709\u503C\u65F6\u5FC5\u987B\u539F\u6837\u4F20\u5165" }, + sourceText: { type: "string", description: "\u539F\u59CB\u7528\u6237\u6587\u672C\uFF0C\u53EF\u9009" } + }, + required: ["title"] + } + }, + { + name: "schedule_create_reminder", + description: "\u4E3A\u5F53\u524D\u7528\u6237\u5DF2\u6709\u4E8B\u9879\u521B\u5EFA\u5355\u6B21\u63D0\u9192\u3002\u5FC5\u987B\u5148\u6709 itemId\uFF0C\u518D\u5199\u5165 h5_schedule_reminders\u3002", + inputSchema: { + type: "object", + properties: { + itemId: { type: "string", description: "\u4E8B\u9879 ID" }, + remindAt: { type: "number", description: "\u63D0\u9192\u89E6\u53D1\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233" }, + offsetMinutes: { type: "number", description: "\u76F8\u5BF9\u4E8B\u9879\u65F6\u95F4\u7684\u63D0\u524D\u5206\u949F\u6570\uFF0C\u53EF\u9009" }, + channel: { type: "string", description: "\u63D0\u9192\u901A\u9053\uFF0C\u9ED8\u8BA4 wechat" } + }, + required: ["itemId", "remindAt"] + } + }, + { + name: "schedule_list_items", + description: "\u67E5\u8BE2\u5F53\u524D\u7528\u6237\u7684\u5F85\u529E/\u65E5\u7A0B\u4E8B\u9879\u5217\u8868\u3002\u4EC5\u8FD4\u56DE\u5F53\u524D\u7528\u6237\u6570\u636E\uFF0C\u53EF\u6309\u65F6\u95F4\u8303\u56F4\u8FC7\u6EE4\u3002", + inputSchema: { + type: "object", + properties: { + from: { type: "number", description: "\u8D77\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, + to: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, + status: { type: "string", description: "\u4E8B\u9879\u72B6\u6001\uFF0C\u9ED8\u8BA4 active\uFF0C\u53EF\u9009" }, + limit: { type: "number", description: "\u8FD4\u56DE\u6570\u91CF\uFF0C\u9ED8\u8BA4 50\uFF0C\u53EF\u9009" } + } + } + } + ); +} +var TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS; +function ensurePrivateDataDb() { + fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true }); + if (!fs.existsSync(PRIVATE_DATA_DB)) { + runSqlite(["-batch", PRIVATE_DATA_DB, "PRAGMA journal_mode=WAL; PRAGMA user_version = 1;"]); + } + return PRIVATE_DATA_DB; +} +function privateDataSize() { + let total = 0; + for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) { + if (file === "private-data.sqlite" || file.startsWith("private-data.sqlite-")) { + total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size; + } + } + return total; +} +function runSqlite(args) { + return execFileSync(SQLITE_BIN, args, { + encoding: "utf8", + timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS, + maxBuffer: 1024 * 1024 + }); +} +function sqliteScalar(sql) { + return Number(runSqlite(["-batch", "-noheader", PRIVATE_DATA_DB, sql]).trim() || 0); +} +async function getQuotaState({ forUpdate = false, conn = null } = {}) { + const pool = getQuotaPool(); + if (!pool) return null; + const db = conn ?? pool; + const [rows] = await db.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces + WHERE user_id = ? + LIMIT 1 ${forUpdate ? "FOR UPDATE" : ""}`, + [PRIVATE_DATA_USER_ID] + ); + const row = rows[0]; + if (!row) return null; + const quotaBytes = Number(row.quota_bytes ?? 0); + const usedBytes = Number(row.used_bytes ?? 0); + const reservedBytes = Number(row.reserved_bytes ?? 0); + return { + id: row.id, + status: row.status, + quotaBytes, + usedBytes, + reservedBytes, + availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes) + }; +} +async function sqliteMaxPagePragmaForQuota(currentBytes) { + const quota = await getQuotaState(); + if (!quota) return ""; + if (quota.status !== "active") throw new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u5199"); + if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) { + throw new Error("\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u914D\u989D\u4E0D\u8DB3"); + } + const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes); + const pageSize = sqliteScalar("PRAGMA page_size;") || 4096; + const currentPages = sqliteScalar("PRAGMA page_count;") || 1; + const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize))); + return `PRAGMA max_page_count=${maxPages};`; +} +async function syncPrivateDataQuota(deltaBytes) { + const pool = getQuotaPool(); + if (!pool || !deltaBytes) return null; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const quota = await getQuotaState({ forUpdate: true, conn }); + if (!quota) { + await conn.rollback(); + return null; + } + if (quota.status !== "active") throw new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u5199"); + if (deltaBytes > quota.availableBytes) { + throw new Error(`\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u914D\u989D\u4E0D\u8DB3\uFF1A\u9700\u8981 ${deltaBytes} \u5B57\u8282\uFF0C\u53EF\u7528 ${quota.availableBytes} \u5B57\u8282`); + } + await conn.query( + `UPDATE h5_user_spaces + SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID] + ); + await conn.commit(); + return { deltaBytes }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} +function stripSqlComments(sql) { + return String(sql ?? "").replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").trim(); +} +function rejectDangerousSql(sql, { readonly = false } = {}) { + const cleaned = stripSqlComments(sql); + if (!cleaned) throw new Error("SQL \u4E0D\u80FD\u4E3A\u7A7A"); + if (cleaned.length > 2e4) throw new Error("SQL \u8FC7\u957F"); + if (/^\s*\./m.test(cleaned)) { + throw new Error("SQL \u4E0D\u5141\u8BB8\u4F7F\u7528 sqlite3 dot command"); + } + if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) { + throw new Error("SQL \u5305\u542B\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u4E0D\u5141\u8BB8\u7684\u64CD\u4F5C"); + } + if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) { + throw new Error("SQL \u5305\u542B\u4E0D\u5141\u8BB8\u7684 PRAGMA"); + } + if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) { + throw new Error("private_data_query \u53EA\u5141\u8BB8 SELECT/WITH"); + } + return cleaned; +} +async function queryPrivateData(sql) { + const db = ensurePrivateDataDb(); + const cleaned = rejectDangerousSql(sql, { readonly: true }); + const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, "")}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`; + const output = runSqlite(["-json", db, limited]); + return output.trim() || "[]"; +} +async function executePrivateData(sql) { + const db = ensurePrivateDataDb(); + const before = privateDataSize(); + if (before > PRIVATE_DATA_MAX_BYTES) throw new Error("\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u5DF2\u8D85\u8FC7\u5927\u5C0F\u9650\u5236"); + const cleaned = rejectDangerousSql(sql); + const quotaPragma = await sqliteMaxPagePragmaForQuota(before); + runSqlite(["-batch", db, `${quotaPragma} +${cleaned}`]); + const after = privateDataSize(); + if (after > PRIVATE_DATA_MAX_BYTES) { + throw new Error(`\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u8D85\u8FC7\u5927\u5C0F\u9650\u5236\uFF1A${after}/${PRIVATE_DATA_MAX_BYTES} \u5B57\u8282`); + } + const delta = after - before; + const quotaSync = await syncPrivateDataQuota(delta); + return `\u5DF2\u6267\u884C\u3002\u5F53\u524D\u6570\u636E\u7A7A\u95F4\u5927\u5C0F ${after} \u5B57\u8282${quotaSync ? `\uFF0C\u5DF2\u540C\u6B65\u7A7A\u95F4\u5360\u7528 ${delta} \u5B57\u8282` : ""}`; +} +async function privateDataSchema() { + const db = ensurePrivateDataDb(); + const output = runSqlite([ + "-json", + db, + `SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk + FROM sqlite_master m + JOIN pragma_table_info(m.name) p + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' + ORDER BY m.name, p.cid` + ]); + return output.trim() || "[]"; +} +async function callTool(name, args) { + if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) { + throw new Error(`\u5DE5\u5177 ${name} \u672A\u6388\u6743`); + } + switch (name) { + case "read_file": { + const abs = resolveSandboxed(args.path); + const content = fs.readFileSync(abs, "utf8"); + return [{ type: "text", text: content }]; + } + case "write_file": { + const abs = resolveSandboxed(args.path); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, args.content ?? "", "utf8"); + return [{ type: "text", text: `\u5DF2\u5199\u5165 ${args.path}\uFF08${(args.content ?? "").length} \u5B57\u8282\uFF09` }]; + } + case "edit_file": { + const abs = resolveSandboxed(args.path); + const original = fs.readFileSync(abs, "utf8"); + const oldStr = args.old_str ?? ""; + if (oldStr && !original.includes(oldStr)) { + throw new Error("edit_file: old_str \u5728\u6587\u4EF6\u4E2D\u4E0D\u5B58\u5728\uFF0C\u66FF\u6362\u5931\u8D25"); + } + const updated = oldStr ? original.replace(oldStr, args.new_str ?? "") : args.new_str ?? ""; + fs.writeFileSync(abs, updated, "utf8"); + return [{ type: "text", text: `\u5DF2\u7F16\u8F91 ${args.path}` }]; + } + case "list_dir": { + const target = args?.path ?? "."; + const abs = resolveSandboxed(target); + const entries = fs.readdirSync(abs, { withFileTypes: true }); + const lines = entries.map((e) => `${e.isDirectory() ? "[\u76EE\u5F55]" : "[\u6587\u4EF6]"} ${e.name}`); + return [{ type: "text", text: lines.join("\n") || "\uFF08\u7A7A\u76EE\u5F55\uFF09" }]; + } + case "create_dir": { + const abs = resolveSandboxed(args.path); + fs.mkdirSync(abs, { recursive: true }); + return [{ type: "text", text: `\u5DF2\u521B\u5EFA\u76EE\u5F55 ${args.path}` }]; + } + case "private_data_info": { + ensurePrivateDataDb(); + const size = privateDataSize(); + const quota = await getQuotaState().catch(() => null); + return [ + { + type: "text", + text: JSON.stringify( + { + name: "\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4", + database: ".mindspace/private-data.sqlite", + maxBytes: PRIVATE_DATA_MAX_BYTES, + sizeBytes: size, + quotaSyncEnabled: Boolean(getQuotaPool()), + quota: quota ? { + status: quota.status, + quotaBytes: quota.quotaBytes, + usedBytes: quota.usedBytes, + reservedBytes: quota.reservedBytes, + availableBytes: quota.availableBytes + } : null, + rules: [ + "\u6BCF\u4E2A\u7528\u6237\u53EA\u80FD\u4F7F\u7528\u8FD9\u4E2A\u552F\u4E00 SQLite \u6570\u636E\u5E93", + "\u9002\u5408\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868", + "\u4E0D\u8981\u5B58\u8D26\u53F7\u3001\u8BA1\u8D39\u3001\u6743\u9650\u3001\u5BA1\u8BA1\u3001\u516C\u5F00\u5E73\u53F0\u6570\u636E\u6216\u8DE8\u7528\u6237\u6570\u636E" + ] + }, + null, + 2 + ) + } + ]; + } + case "private_data_schema": + return [{ type: "text", text: await privateDataSchema() }]; + case "private_data_query": + return [{ type: "text", text: await queryPrivateData(args.sql) }]; + case "private_data_execute": + return [{ type: "text", text: await executePrivateData(args.sql) }]; + case "schedule_create_item": { + const item = await getScheduleService().createItem({ + userId: PRIVATE_DATA_USER_ID, + kind: args.kind, + title: args.title, + description: args.description ?? null, + startAt: args.startAt ?? null, + endAt: args.endAt ?? null, + dueAt: args.dueAt ?? null, + allDay: Boolean(args.allDay), + timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai", + location: args.location ?? null, + sourceChannel: "agent", + sourceMessageId: args.sourceMessageId ?? null, + sourceText: args.sourceText ?? null, + metadata: { source: "schedule_assistant_skill" } + }); + return [{ type: "text", text: JSON.stringify(item, null, 2) }]; + } + case "schedule_create_reminder": { + const reminder = await getScheduleService().createReminder({ + userId: PRIVATE_DATA_USER_ID, + itemId: args.itemId, + remindAt: args.remindAt, + offsetMinutes: args.offsetMinutes ?? null, + channel: args.channel ?? "wechat" + }); + return [{ type: "text", text: JSON.stringify(reminder, null, 2) }]; + } + case "schedule_list_items": { + const items = await getScheduleService().listItems({ + userId: PRIVATE_DATA_USER_ID, + from: args.from ?? null, + to: args.to ?? null, + status: args.status ?? "active", + limit: args.limit ?? 50 + }); + return [{ type: "text", text: JSON.stringify(items, null, 2) }]; + } + default: + throw new Error(`\u672A\u77E5\u5DE5\u5177\uFF1A${name}`); + } +} +function send(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} +function respond(id, result) { + send({ jsonrpc: "2.0", id, result }); +} +function respondError(id, message, code = -32603) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} +var rl = readline.createInterface({ input: process.stdin, terminal: false }); +rl.on("line", async (raw) => { + const line = raw.trim(); + if (!line) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + process.stderr.write(`[mindspace-sandbox-mcp] invalid JSON: ${line} +`); + return; + } + const { id, method, params } = msg; + switch (method) { + case "initialize": + respond(id, { + protocolVersion: "2024-11-05", + serverInfo: { name: "mindspace-sandbox", version: "1.0.0" }, + capabilities: { tools: {} } + }); + break; + case "initialized": + break; + case "tools/list": + respond(id, { tools: TOOLS }); + break; + case "tools/call": { + const { name, arguments: toolArgs } = params ?? {}; + try { + const content = await callTool(name, toolArgs ?? {}); + respond(id, { content, isError: false }); + } catch (err) { + respond(id, { + content: [{ type: "text", text: err.message }], + isError: true + }); + } + break; + } + default: + if (id !== void 0 && id !== null) { + respondError(id, `Method not found: ${method}`, -32601); + } + } +}); +rl.on("close", () => process.exit(0)); diff --git a/.runtime/portal/package.json b/.runtime/portal/package.json new file mode 100644 index 0000000..5497b5a --- /dev/null +++ b/.runtime/portal/package.json @@ -0,0 +1,5 @@ +{ + "name": "tkmind-h5-portal-runtime", + "private": true, + "type": "module" +} diff --git a/.runtime/portal/public/MP_verify_nXjhx0ErC6MhQ0SZ.txt b/.runtime/portal/public/MP_verify_nXjhx0ErC6MhQ0SZ.txt new file mode 100644 index 0000000..c6b1270 --- /dev/null +++ b/.runtime/portal/public/MP_verify_nXjhx0ErC6MhQ0SZ.txt @@ -0,0 +1 @@ +nXjhx0ErC6MhQ0SZ diff --git a/.runtime/portal/public/hello-john.html b/.runtime/portal/public/hello-john.html new file mode 100644 index 0000000..992c5f1 --- /dev/null +++ b/.runtime/portal/public/hello-john.html @@ -0,0 +1,159 @@ + + + + + + + + Hello, John! + + + +
+ + +
+
+ 👋 +

Hello!

+

Welcome, John

+ +
+ + diff --git a/.runtime/portal/public/plaza-covers/business-1bt5ajr.jpg b/.runtime/portal/public/plaza-covers/business-1bt5ajr.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-1bt5ajr.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-1dpghh8.jpg b/.runtime/portal/public/plaza-covers/business-1dpghh8.jpg new file mode 100644 index 0000000..76d994a Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-1dpghh8.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-30-84q88u.jpg b/.runtime/portal/public/plaza-covers/business-30-84q88u.jpg new file mode 100644 index 0000000..a22930d Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-30-84q88u.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-coffee-shop.jpg b/.runtime/portal/public/plaza-covers/business-coffee-shop.jpg new file mode 100644 index 0000000..9117550 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-coffee-shop.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-e09bef.jpg b/.runtime/portal/public/plaza-covers/business-e09bef.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-e09bef.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-nfc9ph.jpg b/.runtime/portal/public/plaza-covers/business-nfc9ph.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-nfc9ph.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-pilates.jpg b/.runtime/portal/public/plaza-covers/business-pilates.jpg new file mode 100644 index 0000000..e7ad1cc Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-pilates.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-sop-33nw2t.jpg b/.runtime/portal/public/plaza-covers/business-sop-33nw2t.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-sop-33nw2t.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-zfht2z.jpg b/.runtime/portal/public/plaza-covers/business-zfht2z.jpg new file mode 100644 index 0000000..2da05d3 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-zfht2z.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg b/.runtime/portal/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg b/.runtime/portal/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg b/.runtime/portal/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg new file mode 100644 index 0000000..2da05d3 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-独立书店文创周边选品策略.jpg b/.runtime/portal/public/plaza-covers/business-独立书店文创周边选品策略.jpg new file mode 100644 index 0000000..76d994a Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-独立书店文创周边选品策略.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg b/.runtime/portal/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg b/.runtime/portal/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg new file mode 100644 index 0000000..a22930d Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg differ diff --git a/.runtime/portal/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg b/.runtime/portal/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-17bb2q5.jpg b/.runtime/portal/public/plaza-covers/creative-17bb2q5.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-17bb2q5.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-1a69un3.jpg b/.runtime/portal/public/plaza-covers/creative-1a69un3.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-1a69un3.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-1wuxajv.jpg b/.runtime/portal/public/plaza-covers/creative-1wuxajv.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-1wuxajv.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-ai-1m59cjr.jpg b/.runtime/portal/public/plaza-covers/creative-ai-1m59cjr.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-ai-1m59cjr.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-ceramics-1o5zmj5.jpg b/.runtime/portal/public/plaza-covers/creative-ceramics-1o5zmj5.jpg new file mode 100644 index 0000000..495339d Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-ceramics-1o5zmj5.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-cyber-city.jpg b/.runtime/portal/public/plaza-covers/creative-cyber-city.jpg new file mode 100644 index 0000000..980e792 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-cyber-city.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-music-ep.jpg b/.runtime/portal/public/plaza-covers/creative-music-ep.jpg new file mode 100644 index 0000000..980e792 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-music-ep.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg b/.runtime/portal/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-vis-tkrnir.jpg b/.runtime/portal/public/plaza-covers/creative-vis-tkrnir.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-vis-tkrnir.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg b/.runtime/portal/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg b/.runtime/portal/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-复古胶片人像调色预设包.jpg b/.runtime/portal/public/plaza-covers/creative-复古胶片人像调色预设包.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-复古胶片人像调色预设包.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg b/.runtime/portal/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg new file mode 100644 index 0000000..495339d Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg b/.runtime/portal/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg b/.runtime/portal/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg differ diff --git a/.runtime/portal/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg b/.runtime/portal/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-1293jxp.jpg b/.runtime/portal/public/plaza-covers/data-analysis-1293jxp.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-1293jxp.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-a-b-1slrtg.jpg b/.runtime/portal/public/plaza-covers/data-analysis-a-b-1slrtg.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-a-b-1slrtg.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg b/.runtime/portal/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg b/.runtime/portal/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-cohort.jpg b/.runtime/portal/public/plaza-covers/data-analysis-cohort.jpg new file mode 100644 index 0000000..b5db113 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-cohort.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-g6tcgg.jpg b/.runtime/portal/public/plaza-covers/data-analysis-g6tcgg.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-g6tcgg.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-gmv.jpg b/.runtime/portal/public/plaza-covers/data-analysis-gmv.jpg new file mode 100644 index 0000000..5b146d7 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-gmv.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg b/.runtime/portal/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg new file mode 100644 index 0000000..a39faf3 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-sql-9sel2d.jpg b/.runtime/portal/public/plaza-covers/data-analysis-sql-9sel2d.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-sql-9sel2d.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-ztijjg.jpg b/.runtime/portal/public/plaza-covers/data-analysis-ztijjg.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-ztijjg.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg b/.runtime/portal/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg b/.runtime/portal/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg b/.runtime/portal/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg b/.runtime/portal/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg b/.runtime/portal/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg new file mode 100644 index 0000000..a39faf3 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg differ diff --git a/.runtime/portal/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg b/.runtime/portal/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-100-40-l2vjup.jpg b/.runtime/portal/public/plaza-covers/lifestyle-100-40-l2vjup.jpg new file mode 100644 index 0000000..e2e61e9 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-100-40-l2vjup.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-1o3s87z.jpg b/.runtime/portal/public/plaza-covers/lifestyle-1o3s87z.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-1o3s87z.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-1wfbcos.jpg b/.runtime/portal/public/plaza-covers/lifestyle-1wfbcos.jpg new file mode 100644 index 0000000..f73669e Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-1wfbcos.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-30-173irnz.jpg b/.runtime/portal/public/plaza-covers/lifestyle-30-173irnz.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-30-173irnz.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg b/.runtime/portal/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-7-1cwt66m.jpg b/.runtime/portal/public/plaza-covers/lifestyle-7-1cwt66m.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-7-1cwt66m.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-desk.jpg b/.runtime/portal/public/plaza-covers/lifestyle-desk.jpg new file mode 100644 index 0000000..8599dcc Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-desk.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-m33c9i.jpg b/.runtime/portal/public/plaza-covers/lifestyle-m33c9i.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-m33c9i.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-o90i4e.jpg b/.runtime/portal/public/plaza-covers/lifestyle-o90i4e.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-o90i4e.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-writing.jpg b/.runtime/portal/public/plaza-covers/lifestyle-writing.jpg new file mode 100644 index 0000000..8599dcc Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-writing.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg b/.runtime/portal/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg new file mode 100644 index 0000000..f73669e Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg b/.runtime/portal/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg b/.runtime/portal/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg b/.runtime/portal/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg b/.runtime/portal/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg new file mode 100644 index 0000000..e2e61e9 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg differ diff --git a/.runtime/portal/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg b/.runtime/portal/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-0-1000-35irqz.jpg b/.runtime/portal/public/plaza-covers/other-0-1000-35irqz.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-0-1000-35irqz.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-176h7rn.jpg b/.runtime/portal/public/plaza-covers/other-176h7rn.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-176h7rn.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-187ouof.jpg b/.runtime/portal/public/plaza-covers/other-187ouof.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-187ouof.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-1nd2tz4.jpg b/.runtime/portal/public/plaza-covers/other-1nd2tz4.jpg new file mode 100644 index 0000000..5dca65c Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-1nd2tz4.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-20-1de41cz.jpg b/.runtime/portal/public/plaza-covers/other-20-1de41cz.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-20-1de41cz.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-opensource.jpg b/.runtime/portal/public/plaza-covers/other-opensource.jpg new file mode 100644 index 0000000..21dfb28 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-opensource.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-vr-60-1yt52pj.jpg b/.runtime/portal/public/plaza-covers/other-vr-60-1yt52pj.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-vr-60-1yt52pj.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-vr-健身-60-天体验报告.jpg b/.runtime/portal/public/plaza-covers/other-vr-健身-60-天体验报告.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-vr-健身-60-天体验报告.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-wishlist.jpg b/.runtime/portal/public/plaza-covers/other-wishlist.jpg new file mode 100644 index 0000000..6bef7a3 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-wishlist.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-xu49pe.jpg b/.runtime/portal/public/plaza-covers/other-xu49pe.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-xu49pe.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-个人知识库搭建方法论.jpg b/.runtime/portal/public/plaza-covers/other-个人知识库搭建方法论.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-个人知识库搭建方法论.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-二手交易向可持续生活转型.jpg b/.runtime/portal/public/plaza-covers/other-二手交易向可持续生活转型.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-二手交易向可持续生活转型.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg b/.runtime/portal/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg b/.runtime/portal/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-社群运营-从群聊到价值观.jpg b/.runtime/portal/public/plaza-covers/other-社群运营-从群聊到价值观.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-社群运营-从群聊到价值观.jpg differ diff --git a/.runtime/portal/public/plaza-covers/other-自由职业者财务规划入门.jpg b/.runtime/portal/public/plaza-covers/other-自由职业者财务规划入门.jpg new file mode 100644 index 0000000..5dca65c Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-自由职业者财务规划入门.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-6owsyr.jpg b/.runtime/portal/public/plaza-covers/study-notes-6owsyr.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-6owsyr.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-docker-1adiwh7.jpg b/.runtime/portal/public/plaza-covers/study-notes-docker-1adiwh7.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-docker-1adiwh7.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg b/.runtime/portal/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-fobvok.jpg b/.runtime/portal/public/plaza-covers/study-notes-fobvok.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-fobvok.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-git-code-review-1avjty.jpg b/.runtime/portal/public/plaza-covers/study-notes-git-code-review-1avjty.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-git-code-review-1avjty.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg b/.runtime/portal/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-llm.jpg b/.runtime/portal/public/plaza-covers/study-notes-llm.jpg new file mode 100644 index 0000000..32c7d0c Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-llm.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-pandas-1mg4mom.jpg b/.runtime/portal/public/plaza-covers/study-notes-pandas-1mg4mom.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-pandas-1mg4mom.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg b/.runtime/portal/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-pca-54364r.jpg b/.runtime/portal/public/plaza-covers/study-notes-pca-54364r.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-pca-54364r.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-rust.jpg b/.runtime/portal/public/plaza-covers/study-notes-rust.jpg new file mode 100644 index 0000000..3fa0a55 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-rust.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg b/.runtime/portal/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg b/.runtime/portal/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg b/.runtime/portal/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg b/.runtime/portal/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg differ diff --git a/.runtime/portal/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg b/.runtime/portal/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-10-ap683v.jpg b/.runtime/portal/public/plaza-covers/travel-10-ap683v.jpg new file mode 100644 index 0000000..d7437c5 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-10-ap683v.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-15qb47w.jpg b/.runtime/portal/public/plaza-covers/travel-15qb47w.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-15qb47w.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-3-1lbpfse.jpg b/.runtime/portal/public/plaza-covers/travel-3-1lbpfse.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-3-1lbpfse.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-4-70oznz.jpg b/.runtime/portal/public/plaza-covers/travel-4-70oznz.jpg new file mode 100644 index 0000000..4490c59 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-4-70oznz.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-48-p5s92m.jpg b/.runtime/portal/public/plaza-covers/travel-48-p5s92m.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-48-p5s92m.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-6-1hwz8po.jpg b/.runtime/portal/public/plaza-covers/travel-6-1hwz8po.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-6-1hwz8po.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-fjj9j0.jpg b/.runtime/portal/public/plaza-covers/travel-fjj9j0.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-fjj9j0.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-kyoto.jpg b/.runtime/portal/public/plaza-covers/travel-kyoto.jpg new file mode 100644 index 0000000..2bd1d1d Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-kyoto.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-malaysia.jpg b/.runtime/portal/public/plaza-covers/travel-malaysia.jpg new file mode 100644 index 0000000..2bd1d1d Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-malaysia.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg b/.runtime/portal/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg b/.runtime/portal/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg new file mode 100644 index 0000000..d7437c5 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg b/.runtime/portal/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg b/.runtime/portal/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg new file mode 100644 index 0000000..4490c59 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg b/.runtime/portal/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg b/.runtime/portal/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg differ diff --git a/.runtime/portal/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg b/.runtime/portal/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-1y4i5h1.jpg b/.runtime/portal/public/plaza-covers/work-report-1y4i5h1.jpg new file mode 100644 index 0000000..206f41a Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-1y4i5h1.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-2025-1fz5i3.jpg b/.runtime/portal/public/plaza-covers/work-report-2025-1fz5i3.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-2025-1fz5i3.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-72ek9j.jpg b/.runtime/portal/public/plaza-covers/work-report-72ek9j.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-72ek9j.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-ai-service.jpg b/.runtime/portal/public/plaza-covers/work-report-ai-service.jpg new file mode 100644 index 0000000..dc1d298 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-ai-service.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-disney.jpg b/.runtime/portal/public/plaza-covers/work-report-disney.jpg new file mode 100644 index 0000000..49f866d Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-disney.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-explore.jpg b/.runtime/portal/public/plaza-covers/work-report-explore.jpg new file mode 100644 index 0000000..ef2d817 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-explore.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-growth.jpg b/.runtime/portal/public/plaza-covers/work-report-growth.jpg new file mode 100644 index 0000000..d8959b2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-growth.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-hello.jpg b/.runtime/portal/public/plaza-covers/work-report-hello.jpg new file mode 100644 index 0000000..5c69410 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-hello.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-mindspace-qwuy2q.jpg b/.runtime/portal/public/plaza-covers/work-report-mindspace-qwuy2q.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-mindspace-qwuy2q.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-mindspace-公开发布指南.jpg b/.runtime/portal/public/plaza-covers/work-report-mindspace-公开发布指南.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-mindspace-公开发布指南.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-okr-ivflp3.jpg b/.runtime/portal/public/plaza-covers/work-report-okr-ivflp3.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-okr-ivflp3.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-q3-1v4dgld.jpg b/.runtime/portal/public/plaza-covers/work-report-q3-1v4dgld.jpg new file mode 100644 index 0000000..509e7ba Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-q3-1v4dgld.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg b/.runtime/portal/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-supply-chain.jpg b/.runtime/portal/public/plaza-covers/work-report-supply-chain.jpg new file mode 100644 index 0000000..1782e04 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-supply-chain.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg b/.runtime/portal/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg b/.runtime/portal/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg b/.runtime/portal/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg b/.runtime/portal/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg new file mode 100644 index 0000000..206f41a Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg b/.runtime/portal/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg differ diff --git a/.runtime/portal/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg b/.runtime/portal/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg new file mode 100644 index 0000000..509e7ba Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg differ diff --git a/.runtime/portal/public/thumbnail-demo/index.html b/.runtime/portal/public/thumbnail-demo/index.html new file mode 100644 index 0000000..6c63f7f --- /dev/null +++ b/.runtime/portal/public/thumbnail-demo/index.html @@ -0,0 +1,67 @@ + + + + + + MindSpace 预览图 Demo + + + +

MindSpace 预览图升级 Demo

+

左侧为旧版横版缩略图,中间为新版 3:4 信息流封面(自动从 HTML 提取标题、配色、emoji),右侧为保存页面时实际写入的 thumbnail.svg。下方是小红书风格卡片效果。

+
+ +
+

样本 1

+
+
旧版旧版(横版字卡)
+
新版新版(3:4 精美封面)
+
生成结果generateHtmlThumbnail 输出
+
+
+
+
马来西亚旅游攻略 预览图
+
+

马来西亚旅游攻略

+

旅行攻略 · 新版 3:4 封面

+
+
+
+
+

样本 2

+
+
旧版旧版(横版字卡)
+
新版新版(3:4 精美封面)
+
生成结果generateHtmlThumbnail 输出
+
+
+
+
麻婆豆腐 预览图
+
+

麻婆豆腐

+

美食专题 · 新版 3:4 封面

+
+
+
+
+ + \ No newline at end of file diff --git a/.runtime/portal/public/thumbnail-demo/malaysia-travel-feed.svg b/.runtime/portal/public/thumbnail-demo/malaysia-travel-feed.svg new file mode 100644 index 0000000..aca64f1 --- /dev/null +++ b/.runtime/portal/public/thumbnail-demo/malaysia-travel-feed.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 旅行攻略 + 马来西亚旅游攻略 + + 马来西亚深度游 + TKMIND + \ No newline at end of file diff --git a/.runtime/portal/public/thumbnail-demo/malaysia-travel-generated.svg b/.runtime/portal/public/thumbnail-demo/malaysia-travel-generated.svg new file mode 100644 index 0000000..aca64f1 --- /dev/null +++ b/.runtime/portal/public/thumbnail-demo/malaysia-travel-generated.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 旅行攻略 + 马来西亚旅游攻略 + + 马来西亚深度游 + TKMIND + \ No newline at end of file diff --git a/.runtime/portal/public/thumbnail-demo/malaysia-travel-legacy.svg b/.runtime/portal/public/thumbnail-demo/malaysia-travel-legacy.svg new file mode 100644 index 0000000..e82c0f0 --- /dev/null +++ b/.runtime/portal/public/thumbnail-demo/malaysia-travel-legacy.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 页面 + 马来西亚旅游攻略 + + 马来西亚深度游 + TKMIND + \ No newline at end of file diff --git a/.runtime/portal/public/thumbnail-demo/mapo-tofu-feed.svg b/.runtime/portal/public/thumbnail-demo/mapo-tofu-feed.svg new file mode 100644 index 0000000..0129022 --- /dev/null +++ b/.runtime/portal/public/thumbnail-demo/mapo-tofu-feed.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 美食专题 + 麻婆豆腐 + + Mapo Tofu — + TKMIND + \ No newline at end of file diff --git a/.runtime/portal/public/thumbnail-demo/mapo-tofu-generated.svg b/.runtime/portal/public/thumbnail-demo/mapo-tofu-generated.svg new file mode 100644 index 0000000..0129022 --- /dev/null +++ b/.runtime/portal/public/thumbnail-demo/mapo-tofu-generated.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 美食专题 + 麻婆豆腐 + + Mapo Tofu — + TKMIND + \ No newline at end of file diff --git a/.runtime/portal/public/thumbnail-demo/mapo-tofu-legacy.svg b/.runtime/portal/public/thumbnail-demo/mapo-tofu-legacy.svg new file mode 100644 index 0000000..3362f19 --- /dev/null +++ b/.runtime/portal/public/thumbnail-demo/mapo-tofu-legacy.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 页面 + 麻婆豆腐 + + Mapo Tofu — + TKMIND + \ No newline at end of file diff --git a/.runtime/portal/schema.sql b/.runtime/portal/schema.sql new file mode 100644 index 0000000..c02695e --- /dev/null +++ b/.runtime/portal/schema.sql @@ -0,0 +1,999 @@ +CREATE TABLE IF NOT EXISTS h5_users ( + id CHAR(36) PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + slug VARCHAR(64) NULL, + email VARCHAR(255) NULL, + display_name VARCHAR(128) NOT NULL, + salt VARCHAR(64) NOT NULL, + password_hash VARCHAR(128) NOT NULL, + password_algorithm VARCHAR(32) NOT NULL DEFAULT 'pbkdf2-sha512', + role ENUM('user', 'admin') NOT NULL DEFAULT 'user', + status ENUM('active', 'suspended', 'disabled') NOT NULL DEFAULT 'active', + plan_type VARCHAR(32) NOT NULL DEFAULT 'free', + workspace_root VARCHAR(512) NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_users_slug (slug), + UNIQUE KEY uq_h5_users_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_spaces ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + space_name VARCHAR(128) NOT NULL DEFAULT '我的空间', + quota_bytes BIGINT NOT NULL DEFAULT 5242880, + used_bytes BIGINT NOT NULL DEFAULT 0, + reserved_bytes BIGINT NOT NULL DEFAULT 0, + status ENUM('active', 'locked', 'deleted') NOT NULL DEFAULT 'active', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_user_space_user (user_id), + CONSTRAINT fk_h5_space_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT chk_h5_space_quota CHECK ( + quota_bytes >= 0 AND used_bytes >= 0 AND reserved_bytes >= 0 + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mindspace_config ( + `key` VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + description VARCHAR(255) NULL, + updated_at BIGINT NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_space_categories ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + space_id CHAR(36) NOT NULL, + category_code VARCHAR(32) NOT NULL, + category_name VARCHAR(128) NOT NULL, + visibility_policy VARCHAR(64) NOT NULL, + ai_access_policy VARCHAR(64) NOT NULL, + publish_policy VARCHAR(64) NOT NULL, + is_system TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_space_category_code (space_id, category_code), + KEY idx_h5_space_category_user (user_id, sort_order), + CONSTRAINT fk_h5_category_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_category_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_assets ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + space_id CHAR(36) NOT NULL, + category_id CHAR(36) NOT NULL, + parent_id CHAR(36) NULL, + asset_type VARCHAR(32) NOT NULL, + mime_type VARCHAR(128) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + current_version_id CHAR(36) NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + checksum CHAR(64) NOT NULL, + risk_level ENUM('none', 'low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'none', + visibility ENUM('private', 'internal', 'public_candidate') NOT NULL DEFAULT 'private', + status ENUM('uploaded', 'processing', 'ready', 'quarantined', 'archived', 'deleted') NOT NULL DEFAULT 'ready', + source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace') NOT NULL DEFAULT 'upload', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT NULL, + KEY idx_h5_assets_category (user_id, category_id, updated_at), + KEY idx_h5_assets_parent (user_id, parent_id), + KEY idx_h5_assets_checksum (user_id, checksum), + CONSTRAINT fk_h5_asset_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_asset_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_asset_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_asset_parent FOREIGN KEY (parent_id) REFERENCES h5_assets(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_asset_versions ( + id CHAR(36) PRIMARY KEY, + asset_id CHAR(36) NOT NULL, + version_no INT NOT NULL, + storage_key VARCHAR(512) NOT NULL, + size_bytes BIGINT NOT NULL, + checksum CHAR(64) NOT NULL, + mime_type VARCHAR(128) NOT NULL, + created_by CHAR(36) NOT NULL, + change_note VARCHAR(512) NULL, + scan_status ENUM('pending', 'passed', 'warned', 'blocked') NOT NULL DEFAULT 'pending', + created_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_asset_version (asset_id, version_no), + UNIQUE KEY uq_h5_asset_storage_key (storage_key), + CONSTRAINT fk_h5_asset_version_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_asset_version_user FOREIGN KEY (created_by) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_upload_sessions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + space_id CHAR(36) NOT NULL, + category_id CHAR(36) NOT NULL, + filename VARCHAR(255) NOT NULL, + expected_size BIGINT NOT NULL, + actual_size BIGINT NULL, + declared_mime_type VARCHAR(128) NULL, + detected_mime_type VARCHAR(128) NULL, + reserved_bytes BIGINT NOT NULL, + temporary_storage_key VARCHAR(512) NOT NULL, + checksum CHAR(64) NULL, + completed_asset_id CHAR(36) NULL, + status ENUM('reserved', 'uploaded', 'completed', 'cancelled', 'expired', 'failed') NOT NULL DEFAULT 'reserved', + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + completed_at BIGINT NULL, + KEY idx_h5_upload_user_status (user_id, status, expires_at), + CONSTRAINT fk_h5_upload_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_upload_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_upload_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_page_records ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + space_id CHAR(36) NOT NULL, + category_id CHAR(36) NOT NULL, + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_asset_id CHAR(36) NULL, + title VARCHAR(255) NOT NULL, + summary VARCHAR(1000) NULL, + cover_image_asset_id CHAR(36) NULL, + page_type VARCHAR(32) NOT NULL DEFAULT 'article', + template_id VARCHAR(64) NOT NULL DEFAULT 'editorial', + draft_content_ref VARCHAR(512) NULL, + current_version_id CHAR(36) NULL, + current_publish_id CHAR(36) NULL, + status ENUM('draft', 'reviewing', 'risk_found', 'ready', 'published', 'protected', 'expired', 'offline', 'deleted') NOT NULL DEFAULT 'draft', + visibility ENUM('private', 'internal', 'public') NOT NULL DEFAULT 'private', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT NULL, + KEY idx_h5_pages_user_status (user_id, status, updated_at), + KEY idx_h5_pages_category (user_id, category_id, updated_at), + KEY idx_h5_pages_source_session (user_id, source_session_id), + CONSTRAINT fk_h5_page_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_page_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_page_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_page_source_asset FOREIGN KEY (source_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL, + CONSTRAINT fk_h5_page_cover_asset FOREIGN KEY (cover_image_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_page_versions ( + id CHAR(36) PRIMARY KEY, + page_id CHAR(36) NOT NULL, + version_no INT NOT NULL, + content_asset_id CHAR(36) NOT NULL, + bundle_asset_id CHAR(36) NULL, + source_snapshot_json JSON NULL, + security_scan_id CHAR(36) NULL, + created_by CHAR(36) NOT NULL, + change_note VARCHAR(512) NULL, + immutable TINYINT(1) NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_page_version (page_id, version_no), + CONSTRAINT fk_h5_page_version_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_page_version_content FOREIGN KEY (content_asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_page_version_bundle FOREIGN KEY (bundle_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL, + CONSTRAINT fk_h5_page_version_user FOREIGN KEY (created_by) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_security_scans ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + target_type ENUM('asset', 'page_version', 'publication_bundle') NOT NULL, + target_id CHAR(36) NOT NULL, + scanner_version VARCHAR(64) NOT NULL, + status ENUM('queued', 'running', 'passed', 'warned', 'blocked', 'failed') NOT NULL, + risk_level ENUM('none', 'low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'none', + findings_count INT NOT NULL DEFAULT 0, + summary_json JSON NULL, + started_at BIGINT NOT NULL, + completed_at BIGINT NULL, + KEY idx_h5_security_scan_target (user_id, target_type, target_id, completed_at), + CONSTRAINT fk_h5_security_scan_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_security_findings ( + id CHAR(36) PRIMARY KEY, + scan_id CHAR(36) NOT NULL, + finding_type VARCHAR(64) NOT NULL, + risk_level ENUM('low', 'medium', 'high', 'critical') NOT NULL, + occurrence_count INT NOT NULL DEFAULT 1, + sample_masked VARCHAR(255) NULL, + blocking TINYINT(1) NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + KEY idx_h5_security_finding_scan (scan_id, risk_level), + CONSTRAINT fk_h5_security_finding_scan FOREIGN KEY (scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_publish_records ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + page_id CHAR(36) NOT NULL, + page_version_id CHAR(36) NOT NULL, + publish_type ENUM('page', 'share') NOT NULL DEFAULT 'page', + url_slug VARCHAR(64) NOT NULL, + public_url VARCHAR(512) NOT NULL, + access_mode ENUM('public', 'password', 'private_link', 'time_limited', 'login_required', 'owner_only') NOT NULL, + password_hash VARCHAR(255) NULL, + token_hash CHAR(64) NULL, + token_prefix VARCHAR(16) NULL, + expires_at BIGINT NULL, + published_at BIGINT NOT NULL, + offline_at BIGINT NULL, + status ENUM('draft', 'online', 'expired', 'offline', 'blocked') NOT NULL DEFAULT 'online', + view_count BIGINT NOT NULL DEFAULT 0, + security_scan_id CHAR(36) NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_publish_route (url_slug, status, published_at), + KEY idx_h5_publish_page (user_id, page_id, published_at), + UNIQUE KEY uq_h5_publish_token_hash (token_hash), + CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_publish_page_version FOREIGN KEY (page_version_id) REFERENCES h5_page_versions(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_publish_scan FOREIGN KEY (security_scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_publication_events ( + id CHAR(36) PRIMARY KEY, + publish_id CHAR(36) NOT NULL, + event_type ENUM('published', 'republished', 'settings_changed', 'expired', 'offlined', 'blocked') NOT NULL, + actor_id CHAR(36) NOT NULL, + old_page_version_id CHAR(36) NULL, + new_page_version_id CHAR(36) NULL, + access_mode VARCHAR(32) NOT NULL, + detail_json JSON NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_publication_event (publish_id, created_at), + CONSTRAINT fk_h5_publication_event_publish FOREIGN KEY (publish_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_publication_event_actor FOREIGN KEY (actor_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_publication_views ( + id CHAR(36) PRIMARY KEY, + publish_id CHAR(36) NOT NULL, + viewer_user_id CHAR(36) NULL, + referrer_host VARCHAR(255) NULL, + device_type ENUM('desktop', 'mobile', 'tablet', 'bot', 'unknown') NOT NULL, + viewed_at BIGINT NOT NULL, + KEY idx_h5_publication_view_time (publish_id, viewed_at), + KEY idx_h5_publication_viewer (viewer_user_id, viewed_at), + CONSTRAINT fk_h5_publication_view_publish FOREIGN KEY (publish_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_publication_view_user FOREIGN KEY (viewer_user_id) REFERENCES h5_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_agent_jobs ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + session_id VARCHAR(128) NULL, + job_type VARCHAR(64) NOT NULL, + instruction TEXT NOT NULL, + permission_scope JSON NULL, + user_context_json JSON NULL, + output_category_id CHAR(36) NOT NULL, + output_type VARCHAR(32) NOT NULL, + max_output_bytes BIGINT NOT NULL DEFAULT 2097152, + status ENUM('queued', 'running', 'completed', 'failed', 'cancelled', 'timed_out') NOT NULL DEFAULT 'queued', + idempotency_key VARCHAR(128) NULL, + progress_json JSON NULL, + job_token_hash CHAR(64) NULL, + result_page_id CHAR(36) NULL, + result_asset_id CHAR(36) NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1000) NULL, + queued_at BIGINT NOT NULL, + started_at BIGINT NULL, + heartbeat_at BIGINT NULL, + completed_at BIGINT NULL, + expires_at BIGINT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_agent_job_idempotency (user_id, idempotency_key), + KEY idx_h5_agent_job_user_status (user_id, status, queued_at), + KEY idx_h5_agent_job_token (job_token_hash), + CONSTRAINT fk_h5_agent_job_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_agent_job_output_category FOREIGN KEY (output_category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_agent_job_result_page FOREIGN KEY (result_page_id) REFERENCES h5_page_records(id) ON DELETE SET NULL, + CONSTRAINT fk_h5_agent_job_result_asset FOREIGN KEY (result_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_agent_job_assets ( + id CHAR(36) PRIMARY KEY, + job_id CHAR(36) NOT NULL, + asset_id CHAR(36) NOT NULL, + asset_version_id CHAR(36) NOT NULL, + permission ENUM('read', 'write', 'create_derivative') NOT NULL DEFAULT 'read', + created_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_agent_job_asset (job_id, asset_id, asset_version_id), + KEY idx_h5_agent_job_asset_job (job_id, created_at), + CONSTRAINT fk_h5_agent_job_asset_job FOREIGN KEY (job_id) REFERENCES h5_agent_jobs(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_agent_job_asset_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_agent_job_asset_version FOREIGN KEY (asset_version_id) REFERENCES h5_asset_versions(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_wallets ( + user_id CHAR(36) PRIMARY KEY, + balance_cents BIGINT NOT NULL DEFAULT 0, + tokens_used BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + CONSTRAINT fk_h5_wallet_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_path_grants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id CHAR(36) NOT NULL, + path VARCHAR(512) NOT NULL, + mode ENUM('read', 'readwrite') NOT NULL DEFAULT 'readwrite', + UNIQUE KEY uq_h5_user_path (user_id, path), + CONSTRAINT fk_h5_path_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_sessions ( + agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, + user_id CHAR(36) NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_user_sessions_user (user_id), + CONSTRAINT fk_h5_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_session_billing_state ( + agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, + user_id CHAR(36) NOT NULL, + last_accumulated_cost DOUBLE NULL, + last_input_tokens BIGINT NOT NULL DEFAULT 0, + last_output_tokens BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + CONSTRAINT fk_h5_billing_state_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_usage_records ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id CHAR(36) NOT NULL, + agent_session_id VARCHAR(128) NOT NULL, + request_id VARCHAR(128) NULL, + input_tokens INT NOT NULL DEFAULT 0, + output_tokens INT NOT NULL DEFAULT 0, + cost_cents BIGINT NOT NULL, + balance_after_cents BIGINT NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_usage_user_time (user_id, created_at), + CONSTRAINT fk_h5_usage_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_policies ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + subject_type ENUM('role', 'user') NOT NULL, + subject_id VARCHAR(64) NOT NULL, + policy_key VARCHAR(64) NOT NULL, + policy_value VARCHAR(512) NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_user_policy (subject_type, subject_id, policy_key), + KEY idx_h5_policy_subject (subject_type, subject_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_skill_grants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + subject_type ENUM('role', 'user') NOT NULL, + subject_id VARCHAR(64) NOT NULL, + skill_name VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_user_skill_grant (subject_type, subject_id, skill_name), + KEY idx_h5_skill_subject (subject_type, subject_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_capability_grants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + subject_type ENUM('role', 'user') NOT NULL, + subject_id VARCHAR(64) NOT NULL, + capability_key VARCHAR(64) NOT NULL, + allowed TINYINT(1) NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_capability_grant (subject_type, subject_id, capability_key), + KEY idx_h5_capability_subject (subject_type, subject_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_login_sessions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + revoked_at BIGINT NULL, + UNIQUE KEY uq_h5_login_token_hash (token_hash), + KEY idx_h5_login_session_user (user_id), + KEY idx_h5_login_session_expires (expires_at), + CONSTRAINT fk_h5_login_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_mindspace_audit_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id CHAR(36) NOT NULL, + action VARCHAR(64) NOT NULL, + object_type VARCHAR(32) NOT NULL, + object_id CHAR(36) NOT NULL, + ip VARCHAR(64) NULL, + result VARCHAR(32) NOT NULL, + risk_level VARCHAR(16) NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_mindspace_audit_user_time (user_id, created_at), + CONSTRAINT fk_h5_mindspace_audit_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_billing_ledger ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('recharge', 'deduct', 'refund', 'adjust') NOT NULL, + amount_cents BIGINT NOT NULL, + tokens BIGINT NOT NULL DEFAULT 0, + session_id VARCHAR(128) NULL, + note VARCHAR(512) NULL, + operator_id CHAR(36) NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_ledger_user (user_id, created_at), + CONSTRAINT fk_h5_ledger_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_llm_provider_keys ( + id CHAR(36) PRIMARY KEY, + provider_id VARCHAR(64) NOT NULL, + provider_kind ENUM('builtin', 'custom') NOT NULL DEFAULT 'builtin', + api_url VARCHAR(512) NULL, + base_path VARCHAR(256) NULL, + models_json TEXT NULL, + goosed_provider_id VARCHAR(64) NULL, + engine VARCHAR(32) NOT NULL DEFAULT 'openai', + relay_provider VARCHAR(64) NULL, + name VARCHAR(128) NOT NULL, + api_key_ciphertext TEXT NOT NULL, + api_key_iv VARCHAR(24) NOT NULL, + api_key_tag VARCHAR(24) NOT NULL, + default_model VARCHAR(128) NOT NULL, + status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', + is_selected TINYINT(1) NOT NULL DEFAULT 0, + is_vision_selected TINYINT(1) NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_llm_key_name (name), + KEY idx_h5_llm_key_provider (provider_id), + KEY idx_h5_llm_key_selected (is_selected), + KEY idx_h5_llm_key_vision (is_vision_selected) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( + id CHAR(36) PRIMARY KEY, + executor ENUM('goose', 'aider', 'openhands') NOT NULL, + purpose VARCHAR(32) NOT NULL DEFAULT 'default', + provider_key_id CHAR(36) NULL, + model VARCHAR(128) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_llm_executor_binding (executor, purpose), + KEY idx_h5_llm_executor_provider (provider_key_id), + CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_payment_orders ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + amount_cents BIGINT NOT NULL, + channel ENUM('wechat') NOT NULL DEFAULT 'wechat', + status ENUM('pending', 'paid', 'failed', 'expired', 'refunded') NOT NULL DEFAULT 'pending', + pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native', + out_trade_no VARCHAR(64) NOT NULL, + provider_txn VARCHAR(128) NULL, + code_url VARCHAR(512) NULL, + h5_url VARCHAR(1024) NULL, + client_ip VARCHAR(64) NULL, + expire_at BIGINT NOT NULL, + paid_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_payment_out_trade_no (out_trade_no), + KEY idx_h5_payment_user_time (user_id, created_at), + KEY idx_h5_payment_status_expire (status, expire_at), + CONSTRAINT fk_h5_payment_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_categories ( + id CHAR(36) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + slug VARCHAR(50) NOT NULL, + icon VARCHAR(10) NOT NULL DEFAULT '', + description VARCHAR(200) NOT NULL DEFAULT '', + sort_order INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + UNIQUE KEY uq_plaza_category_slug (slug), + KEY idx_plaza_category_sort (is_active, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_posts ( + id CHAR(36) PRIMARY KEY, + publication_id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL, + title VARCHAR(200) NOT NULL, + summary VARCHAR(500) NOT NULL DEFAULT '', + cover_url VARCHAR(500) NOT NULL DEFAULT '', + user_slug VARCHAR(100) NOT NULL, + user_display_name VARCHAR(100) NOT NULL, + user_avatar_url VARCHAR(500) NOT NULL DEFAULT '', + category_id CHAR(36) NOT NULL, + tags JSON NOT NULL, + status ENUM('pending_review', 'published', 'hidden', 'rejected') NOT NULL DEFAULT 'pending_review', + view_count INT UNSIGNED NOT NULL DEFAULT 0, + like_count INT UNSIGNED NOT NULL DEFAULT 0, + collect_count INT UNSIGNED NOT NULL DEFAULT 0, + comment_count INT UNSIGNED NOT NULL DEFAULT 0, + share_count INT UNSIGNED NOT NULL DEFAULT 0, + hot_score DOUBLE NOT NULL DEFAULT 0, + hot_updated_at BIGINT NULL, + allow_comment TINYINT(1) NOT NULL DEFAULT 1, + published_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_plaza_post_publication (publication_id), + KEY idx_plaza_post_user_status (user_id, status), + KEY idx_plaza_post_category_hot (category_id, status, hot_score), + KEY idx_plaza_post_published_at (published_at), + KEY idx_plaza_post_status_hot (status, hot_score), + CONSTRAINT fk_plaza_post_publication FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_post_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_post_category FOREIGN KEY (category_id) REFERENCES plaza_categories(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_reactions ( + id CHAR(36) PRIMARY KEY, + post_id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL, + type ENUM('like', 'collect', 'share') NOT NULL, + created_at BIGINT NOT NULL, + UNIQUE KEY uq_plaza_reaction (post_id, user_id, type), + KEY idx_plaza_reaction_post (post_id, type), + KEY idx_plaza_reaction_user (user_id, type), + CONSTRAINT fk_plaza_reaction_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_reaction_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_comments ( + id CHAR(36) PRIMARY KEY, + post_id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL, + parent_id CHAR(36) NULL, + content VARCHAR(500) NOT NULL, + status ENUM('visible', 'deleted', 'flagged', 'hidden') NOT NULL DEFAULT 'visible', + like_count INT UNSIGNED NOT NULL DEFAULT 0, + reply_count INT UNSIGNED NOT NULL DEFAULT 0, + deleted_by ENUM('user', 'ops') NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_plaza_comment_post (post_id, status, created_at), + KEY idx_plaza_comment_parent (parent_id), + KEY idx_plaza_comment_user (user_id, created_at), + CONSTRAINT fk_plaza_comment_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_comment_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_comment_reactions ( + id CHAR(36) PRIMARY KEY, + comment_id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL, + created_at BIGINT NOT NULL, + UNIQUE KEY uq_plaza_comment_reaction (comment_id, user_id), + KEY idx_plaza_comment_reaction_comment (comment_id), + CONSTRAINT fk_plaza_comment_reaction_comment FOREIGN KEY (comment_id) REFERENCES plaza_comments(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_comment_reaction_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_follows ( + follower_id CHAR(36) NOT NULL, + followee_id CHAR(36) NOT NULL, + created_at BIGINT NOT NULL, + PRIMARY KEY (follower_id, followee_id), + KEY idx_plaza_follow_followee (followee_id, created_at), + CONSTRAINT fk_plaza_follow_follower FOREIGN KEY (follower_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_follow_followee FOREIGN KEY (followee_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_algorithm_config ( + `key` VARCHAR(100) NOT NULL, + value DOUBLE NOT NULL, + description VARCHAR(200) NOT NULL DEFAULT '', + updated_at BIGINT NOT NULL, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_attribution_events ( + id CHAR(36) PRIMARY KEY, + event_type ENUM('landing', 'signup') NOT NULL, + utm_source VARCHAR(100) NOT NULL, + utm_medium VARCHAR(100) NOT NULL DEFAULT '', + utm_campaign VARCHAR(100) NOT NULL DEFAULT '', + ref_id VARCHAR(200) NOT NULL DEFAULT '', + user_id CHAR(36) NULL, + ip_hash VARCHAR(64) NOT NULL DEFAULT '', + created_at BIGINT NOT NULL, + KEY idx_plaza_attr_created (created_at), + KEY idx_plaza_attr_campaign (utm_source, utm_campaign, created_at), + CONSTRAINT fk_plaza_attr_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_reports ( + id CHAR(36) PRIMARY KEY, + target_type ENUM('post', 'comment') NOT NULL, + target_id CHAR(36) NOT NULL, + reporter_id CHAR(36) NOT NULL, + reason ENUM('spam', 'violence', 'porn', 'political', 'privacy', 'other') NOT NULL, + detail VARCHAR(500) NOT NULL DEFAULT '', + status ENUM('pending', 'processed', 'dismissed') NOT NULL DEFAULT 'pending', + processed_by CHAR(36) NULL, + processed_at BIGINT NULL, + action_taken VARCHAR(200) NOT NULL DEFAULT '', + created_at BIGINT NOT NULL, + KEY idx_plaza_report_status (status, target_type, created_at), + KEY idx_plaza_report_target (target_type, target_id), + CONSTRAINT fk_plaza_report_reporter FOREIGN KEY (reporter_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_report_processor FOREIGN KEY (processed_by) REFERENCES h5_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Session snapshot cache: stores userVisible messages for fast history reload. +-- Rollback: DROP TABLE h5_session_snapshots (data-only, no dependants). +CREATE TABLE IF NOT EXISTS h5_session_snapshots ( + agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, + user_id CHAR(36) NOT NULL, + name VARCHAR(512) NOT NULL DEFAULT '', + working_dir VARCHAR(1024) NOT NULL DEFAULT '', + created_at_str VARCHAR(64) NOT NULL DEFAULT '', + updated_at_str VARCHAR(64) NOT NULL DEFAULT '', + user_set_name TINYINT(1) NOT NULL DEFAULT 0, + recipe_json TEXT NULL, + synced_msg_count INT NOT NULL DEFAULT 0, + source_updated_at VARCHAR(64) NOT NULL DEFAULT '', + messages_json LONGTEXT NOT NULL, + synced_at BIGINT NOT NULL, + KEY idx_h5_snapshots_user (user_id), + KEY idx_h5_snapshots_synced (synced_at), + CONSTRAINT fk_h5_snapshot_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_featured ( + id CHAR(36) PRIMARY KEY, + post_id CHAR(36) NOT NULL, + position VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + starts_at BIGINT NOT NULL, + expires_at BIGINT NULL, + created_by CHAR(36) NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_plaza_featured_position (position, starts_at, expires_at), + CONSTRAINT fk_plaza_featured_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_featured_creator FOREIGN KEY (created_by) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plaza_user_events ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NULL, + session_id VARCHAR(64) NOT NULL, + post_id CHAR(36) NOT NULL, + event_type ENUM( + 'impression', 'click', 'view', 'dwell', + 'like', 'collect', 'comment', 'share', + 'dislike', 'hide' + ) NOT NULL, + dwell_ms INT UNSIGNED NULL, + feed_sort VARCHAR(32) NOT NULL DEFAULT '', + feed_category VARCHAR(64) NOT NULL DEFAULT '', + position INT UNSIGNED NULL, + created_at BIGINT NOT NULL, + KEY idx_plaza_event_user_time (user_id, created_at), + KEY idx_plaza_event_session_time (session_id, created_at), + KEY idx_plaza_event_post (post_id, event_type, created_at), + CONSTRAINT fk_plaza_event_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE, + CONSTRAINT fk_plaza_event_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS ops_audit_log ( + id CHAR(36) PRIMARY KEY, + operator_id CHAR(36) NOT NULL, + action VARCHAR(100) NOT NULL, + target_type VARCHAR(50) NOT NULL, + target_id CHAR(36) NOT NULL, + detail JSON NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_ops_audit_operator (operator_id, created_at), + KEY idx_ops_audit_target (target_type, target_id, created_at), + CONSTRAINT fk_ops_audit_operator FOREIGN KEY (operator_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_wechat_identities ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + unionid VARCHAR(64) NULL, + nickname VARCHAR(128) NULL, + avatar_url VARCHAR(512) NULL, + last_login_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_wechat_openid (app_id, openid), + UNIQUE KEY uq_wechat_user (user_id, app_id), + KEY idx_wechat_unionid (unionid), + CONSTRAINT fk_wechat_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_wechat_oauth_states ( + state VARCHAR(64) PRIMARY KEY, + return_to VARCHAR(512) NULL, + utm_source VARCHAR(64) NULL, + utm_medium VARCHAR(64) NULL, + utm_campaign VARCHAR(64) NULL, + intent VARCHAR(16) NOT NULL DEFAULT 'login', + bind_user_id CHAR(36) NULL, + auth_mode VARCHAR(8) NOT NULL DEFAULT 'mp', + status VARCHAR(16) NOT NULL DEFAULT 'pending', + result_kind VARCHAR(32) NULL, + result_token VARCHAR(512) NULL, + result_message VARCHAR(255) NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_oauth_state_expires (expires_at), + KEY idx_wechat_oauth_state_status (status, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds ( + token VARCHAR(64) PRIMARY KEY, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + unionid VARCHAR(64) NULL, + nickname VARCHAR(128) NULL, + avatar_url VARCHAR(512) NULL, + return_to VARCHAR(512) NULL, + utm_source VARCHAR(64) NULL, + utm_medium VARCHAR(64) NULL, + utm_campaign VARCHAR(64) NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_pending_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_wechat_agent_routes ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + agent_session_id VARCHAR(128) NOT NULL, + status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_wechat_agent_route (app_id, openid), + KEY idx_wechat_agent_route_user (user_id, updated_at), + CONSTRAINT fk_wechat_agent_route_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_wechat_mp_messages ( + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + msg_id VARCHAR(128) NOT NULL, + status ENUM('processing', 'done', 'failed') NOT NULL DEFAULT 'processing', + agent_session_id VARCHAR(128) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (app_id, openid, msg_id), + KEY idx_wechat_mp_messages_updated (updated_at), + KEY idx_wechat_mp_messages_session (agent_session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_wechat_mp_message_details ( + id CHAR(36) PRIMARY KEY, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + user_id CHAR(36) NULL, + msg_id VARCHAR(128) NULL, + msg_type VARCHAR(32) NOT NULL, + display_text TEXT NULL, + agent_text TEXT NULL, + media_id VARCHAR(256) NULL, + media_url TEXT NULL, + media_public_url TEXT NULL, + media_format VARCHAR(64) NULL, + location_lat DECIMAL(10,7) NULL, + location_lng DECIMAL(10,7) NULL, + location_label VARCHAR(255) NULL, + link_url TEXT NULL, + link_title VARCHAR(255) NULL, + raw_xml_hash CHAR(40) NULL, + raw_json JSON NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_mp_message_details_user (app_id, openid, created_at), + KEY idx_wechat_mp_message_details_msg (msg_id), + KEY idx_wechat_mp_message_details_type (msg_type, created_at), + CONSTRAINT fk_wechat_mp_message_details_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_schedule_items ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + kind ENUM('task', 'event') NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + status ENUM('active', 'completed', 'cancelled', 'deleted') NOT NULL DEFAULT 'active', + start_at BIGINT NULL, + end_at BIGINT NULL, + due_at BIGINT NULL, + all_day TINYINT(1) NOT NULL DEFAULT 0, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + location VARCHAR(255) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + metadata_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT NULL, + KEY idx_schedule_user_time (user_id, status, start_at, due_at), + KEY idx_schedule_user_updated (user_id, updated_at), + CONSTRAINT fk_schedule_item_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_schedule_reminders ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + item_id CHAR(36) NOT NULL, + remind_at BIGINT NOT NULL, + offset_minutes INT NULL, + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('pending', 'locked', 'sent', 'failed', 'cancelled') NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + last_error VARCHAR(500) NULL, + locked_until BIGINT NULL, + sent_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_schedule_item_remind_at (item_id, remind_at, channel), + KEY idx_reminder_due (status, remind_at), + KEY idx_reminder_user (user_id, status, remind_at), + CONSTRAINT fk_schedule_reminder_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_reminder_item FOREIGN KEY (item_id) REFERENCES h5_schedule_items(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_schedule_digest_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + digest_type ENUM('todo_day') NOT NULL DEFAULT 'todo_day', + hour TINYINT UNSIGNED NOT NULL, + minute TINYINT UNSIGNED NOT NULL DEFAULT 0, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NOT NULL, + last_run_at BIGINT NULL, + attempts INT NOT NULL DEFAULT 0, + locked_until BIGINT NULL, + last_error VARCHAR(500) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_schedule_digest_user_type_channel (user_id, digest_type, channel), + KEY idx_schedule_digest_due (status, next_run_at), + CONSTRAINT fk_schedule_digest_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_balance_alert_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + threshold_cents BIGINT NOT NULL, + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NOT NULL, + last_run_at BIGINT NULL, + last_notified_balance_cents BIGINT NULL, + attempts INT NOT NULL DEFAULT 0, + locked_until BIGINT NULL, + last_error VARCHAR(500) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_balance_alert_user_channel (user_id, channel), + KEY idx_balance_alert_due (status, next_run_at), + KEY idx_balance_alert_user (user_id, status, next_run_at), + CONSTRAINT fk_balance_alert_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( + id CHAR(36) PRIMARY KEY, + reminder_id CHAR(36) NULL, + subscription_id CHAR(36) NULL, + user_id CHAR(36) NOT NULL, + channel ENUM('wechat', 'in_app') NOT NULL, + status ENUM('success', 'failed') NOT NULL, + provider_message_id VARCHAR(128) NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(500) NULL, + created_at BIGINT NOT NULL, + KEY idx_delivery_reminder (reminder_id, created_at), + KEY idx_delivery_subscription (subscription_id, created_at), + KEY idx_delivery_user (user_id, created_at), + CONSTRAINT fk_schedule_delivery_reminder FOREIGN KEY (reminder_id) REFERENCES h5_schedule_reminders(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_delivery_subscription FOREIGN KEY (subscription_id) REFERENCES h5_schedule_digest_subscriptions(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_delivery_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_notifications ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + channel ENUM('web', 'wechat') NOT NULL DEFAULT 'web', + notification_type VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + data_json JSON NULL, + status ENUM('unread', 'read') NOT NULL DEFAULT 'unread', + read_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_user_notifications_user_status_created (user_id, status, created_at), + KEY idx_user_notifications_user_created (user_id, created_at), + CONSTRAINT fk_user_notifications_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_plan_catalog ( + plan_type VARCHAR(32) PRIMARY KEY, + name VARCHAR(64) NOT NULL, + price_cents INT NOT NULL DEFAULT 0, + period_days INT NOT NULL DEFAULT 30, + period_tokens BIGINT NOT NULL DEFAULT 0, + period_images INT NOT NULL DEFAULT 0, + model_tier VARCHAR(32) NOT NULL DEFAULT 'basic', + overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00, + sort_order INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + description VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + plan_type VARCHAR(32) NOT NULL DEFAULT 'free', + status ENUM('active', 'expired', 'cancelled') NOT NULL DEFAULT 'active', + period_tokens_limit BIGINT NOT NULL DEFAULT 0, + period_tokens_used BIGINT NOT NULL DEFAULT 0, + period_images_limit INT NOT NULL DEFAULT 0, + period_images_used INT NOT NULL DEFAULT 0, + period_start BIGINT NOT NULL, + period_end BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + operator_id CHAR(36) NULL, + note VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_sub_user_status (user_id, status), + KEY idx_h5_sub_expires (expires_at, status), + CONSTRAINT fk_h5_sub_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_blocked_words ( + id CHAR(36) PRIMARY KEY, + word VARCHAR(200) NOT NULL, + replacement VARCHAR(200) NOT NULL DEFAULT '***', + note VARCHAR(500) NULL, + status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uk_blocked_word (word) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/.runtime/portal/scripts/run-memind-portal-prod.sh b/.runtime/portal/scripts/run-memind-portal-prod.sh new file mode 100755 index 0000000..9ee3f83 --- /dev/null +++ b/.runtime/portal/scripts/run-memind-portal-prod.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if [[ -f "${ROOT}/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "${ROOT}/.env" + set +a +fi + +export NODE_ENV=production +export H5_PORT="${H5_PORT:-8081}" +export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}" +export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}" +export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}" + +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" +if [[ ! -x "${NODE_BIN}" ]]; then + NODE_BIN="$(command -v node)" +fi + +exec "${NODE_BIN}" "${ROOT}/server.mjs" diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs new file mode 100644 index 0000000..b5adb9a --- /dev/null +++ b/.runtime/portal/server.mjs @@ -0,0 +1,26897 @@ +import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url); + +// server.mjs +import express2 from "express"; +import crypto29 from "node:crypto"; +import fs23 from "node:fs"; +import { createProxyMiddleware } from "http-proxy-middleware"; +import path22 from "node:path"; +import { fileURLToPath as fileURLToPath6 } from "node:url"; + +// auth.mjs +import crypto from "node:crypto"; +var AUTH_COOKIE = "tkmind_h5_session"; +function parseCookies(header = "") { + return Object.fromEntries( + header.split(";").map((part) => part.trim()).filter(Boolean).map((part) => { + const separator = part.indexOf("="); + if (separator < 0) return [part, ""]; + return [ + decodeURIComponent(part.slice(0, separator)), + decodeURIComponent(part.slice(separator + 1)) + ]; + }) + ); +} +function safeEqual(left, right) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} +function createAuthManager({ + password, + ttlMs = 7 * 24 * 60 * 60 * 1e3, + maxFailures = 5, + failureWindowMs = 5 * 60 * 1e3 +}) { + if (!password) { + throw new Error("H5_ACCESS_PASSWORD is required"); + } + const sessions = /* @__PURE__ */ new Map(); + const failures = /* @__PURE__ */ new Map(); + const prune = (now = Date.now()) => { + for (const [token, expiresAt] of sessions) { + if (expiresAt <= now) sessions.delete(token); + } + for (const [ip, state] of failures) { + if (state.resetAt <= now) failures.delete(ip); + } + }; + const login = (candidate, ip = "unknown", now = Date.now()) => { + prune(now); + const failure = failures.get(ip); + if (failure && failure.count >= maxFailures && failure.resetAt > now) { + return { ok: false, retryAfterMs: failure.resetAt - now }; + } + if (!safeEqual(candidate, password)) { + const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + failureWindowMs }; + current.count += 1; + failures.set(ip, current); + return { ok: false, retryAfterMs: 0 }; + } + failures.delete(ip); + const token = crypto.randomBytes(32).toString("base64url"); + sessions.set(token, now + ttlMs); + return { ok: true, token }; + }; + const verify = (token, now = Date.now()) => { + if (!token) return false; + prune(now); + const expiresAt = sessions.get(token); + if (!expiresAt || expiresAt <= now) { + sessions.delete(token); + return false; + } + sessions.set(token, now + ttlMs); + return true; + }; + const revoke = (token) => { + if (token) sessions.delete(token); + }; + return { login, verify, revoke }; +} +function sessionCookie(token, secure) { + const parts = [ + `${AUTH_COOKIE}=${encodeURIComponent(token)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Max-Age=604800" + ]; + if (secure) parts.push("Secure"); + return parts.join("; "); +} +function clearSessionCookie(secure) { + const parts = [ + `${AUTH_COOKIE}=`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Max-Age=0" + ]; + if (secure) parts.push("Secure"); + return parts.join("; "); +} + +// db.mjs +import fs2 from "node:fs"; +import mysql from "mysql2/promise"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// mindspace.mjs +import crypto2 from "node:crypto"; + +// mindspace-config.mjs +var PUBLIC_PAGE_LIMIT_KEY = "public_page_limit"; +function asPositiveInteger(value, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} +async function ensureConfigTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS mindspace_config ( + \`key\` VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + description VARCHAR(255) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} +function defaultMindSpaceConfig(env = process.env) { + return { + publicPageLimit: asPositiveInteger(env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5, 5) + }; +} +async function readConfigRows(pool) { + const [rows] = await pool.query( + "SELECT `key`, value FROM mindspace_config" + ); + return rows; +} +async function ensureMindSpaceConfig(pool, { env = process.env, seedDefault = true } = {}) { + await ensureConfigTable(pool); + if (!seedDefault) return; + const [rows] = await pool.query("SELECT COUNT(*) AS count FROM mindspace_config"); + if (Number(rows[0]?.count ?? 0) > 0) return; + const now = Date.now(); + const defaults = defaultMindSpaceConfig(env); + await pool.query( + `INSERT INTO mindspace_config (\`key\`, value, description, updated_at) + VALUES (?, ?, ?, ?)`, + [PUBLIC_PAGE_LIMIT_KEY, String(defaults.publicPageLimit), "\u516C\u5F00\u9875\u9762\u6570\u91CF\u4E0A\u9650", now] + ); +} +async function loadMindSpaceConfig(pool, { env = process.env } = {}) { + const config = defaultMindSpaceConfig(env); + try { + const rows = await readConfigRows(pool); + for (const row of rows) { + if (row.key === PUBLIC_PAGE_LIMIT_KEY) { + config.publicPageLimit = asPositiveInteger(row.value, config.publicPageLimit); + } + } + } catch (error) { + if (error?.code === "ER_NO_SUCH_TABLE") return config; + throw error; + } + return config; +} + +// mindspace.mjs +var DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024; +var DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; +var SYSTEM_CATEGORIES = Object.freeze([ + { + code: "oa", + name: "OA \u5DE5\u4F5C\u533A", + visibilityPolicy: "private", + aiAccessPolicy: "selected_assets", + publishPolicy: "derivative_only", + sortOrder: 10 + }, + { + code: "private", + name: "\u79C1\u4EBA\u533A", + visibilityPolicy: "private", + aiAccessPolicy: "explicit_asset_grant", + publishPolicy: "desensitized_copy_only", + sortOrder: 20 + }, + { + code: "public", + name: "\u516C\u5F00\u533A", + visibilityPolicy: "public_candidate", + aiAccessPolicy: "selected_assets", + publishPolicy: "security_scan_required", + sortOrder: 30 + }, + { + code: "draft", + name: "\u9875\u9762\u8349\u7A3F", + visibilityPolicy: "private", + aiAccessPolicy: "job_output", + publishPolicy: "security_scan_required", + sortOrder: 40 + }, + { + code: "archive", + name: "\u5F52\u6863\u533A", + visibilityPolicy: "private", + aiAccessPolicy: "none", + publishPolicy: "forbidden", + sortOrder: 50 + } +]); +function asNumber(value) { + return Number(value ?? 0); +} +function categoryResponse(row) { + return { + id: row.id, + code: row.category_code, + name: row.category_name, + visibilityPolicy: row.visibility_policy, + aiAccessPolicy: row.ai_access_policy, + publishPolicy: row.publish_policy, + isSystem: Boolean(row.is_system), + sortOrder: asNumber(row.sort_order), + itemCount: asNumber(row.item_count) + }; +} +async function initializeDefaultSpace(db, userId, { + quotaBytes = DEFAULT_SPACE_QUOTA_BYTES, + spaceName = "\u6211\u7684\u7A7A\u95F4", + now = Date.now(), + idFactory = () => crypto2.randomUUID() +} = {}) { + const spaceId = idFactory(); + await db.query( + `INSERT INTO h5_user_spaces + (id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, 0, 'active', ?, ?) + ON DUPLICATE KEY UPDATE user_id = user_id`, + [spaceId, userId, spaceName, quotaBytes, now, now] + ); + const [spaces] = await db.query( + `SELECT id FROM h5_user_spaces WHERE user_id = ? LIMIT 1`, + [userId] + ); + const resolvedSpaceId = spaces[0]?.id; + if (!resolvedSpaceId) { + throw new Error("\u7528\u6237\u7A7A\u95F4\u521D\u59CB\u5316\u5931\u8D25"); + } + for (const category of SYSTEM_CATEGORIES) { + await db.query( + `INSERT INTO h5_space_categories + (id, user_id, space_id, category_code, category_name, visibility_policy, + ai_access_policy, publish_policy, is_system, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) + ON DUPLICATE KEY UPDATE + category_name = VALUES(category_name), + visibility_policy = VALUES(visibility_policy), + ai_access_policy = VALUES(ai_access_policy), + publish_policy = VALUES(publish_policy), + is_system = 1, + sort_order = VALUES(sort_order), + updated_at = VALUES(updated_at)`, + [ + idFactory(), + userId, + resolvedSpaceId, + category.code, + category.name, + category.visibilityPolicy, + category.aiAccessPolicy, + category.publishPolicy, + category.sortOrder, + now, + now + ] + ); + } + return resolvedSpaceId; +} +async function ensureDefaultSpaces(pool, options = {}) { + const [users] = await pool.query( + `SELECT u.id + FROM h5_users u + LEFT JOIN h5_user_spaces s ON s.user_id = u.id + WHERE u.role = 'user' AND s.id IS NULL` + ); + for (const user of users) { + await initializeDefaultSpace(pool, user.id, options); + } + return users.length; +} +function createMindSpaceService(pool, options = {}) { + const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES); + const aiDailyLimit = Number(options.aiDailyLimit ?? 10); + const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); + const monthlyViewLimit = Number(options.monthlyViewLimit ?? 1e3); + const scheduleService2 = options.scheduleService ?? null; + const resolvePublicPageLimit = async () => { + try { + const config = await loadMindSpaceConfig(pool); + return Number(config.publicPageLimit ?? publicPageLimitFallback); + } catch { + return publicPageLimitFallback; + } + }; + const getSpace = async (userId) => { + const [spaces] = await pool.query( + `SELECT id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status, + created_at, updated_at + FROM h5_user_spaces + WHERE user_id = ? + LIMIT 1`, + [userId] + ); + const row = spaces[0]; + if (!row) return null; + const [categories] = await pool.query( + `SELECT c.id, c.category_code, c.category_name, c.visibility_policy, + c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order, + CASE + WHEN c.category_code = 'draft' THEN COUNT(DISTINCT p.id) + WHEN c.category_code = 'public' THEN COUNT(DISTINCT pr.page_id) + ELSE COUNT(DISTINCT a.id) + END AS item_count + FROM h5_space_categories c + LEFT JOIN h5_assets a + ON a.category_id = c.id + AND a.user_id = c.user_id + AND a.status <> 'deleted' + AND a.source_type = 'upload' + LEFT JOIN h5_page_records p + ON p.category_id = c.id AND p.user_id = c.user_id AND p.status <> 'deleted' + LEFT JOIN h5_publish_records pr + ON pr.user_id = c.user_id AND pr.status = 'online' + WHERE c.user_id = ? AND c.space_id = ? + GROUP BY c.id, c.category_code, c.category_name, c.visibility_policy, + c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order + ORDER BY c.sort_order, c.category_name`, + [userId, row.id] + ); + const quotaBytes = asNumber(row.quota_bytes); + const usedBytes = asNumber(row.used_bytes); + const reservedBytes = asNumber(row.reserved_bytes); + const [publicationUsage] = await pool.query( + `SELECT COUNT(DISTINCT CASE WHEN status = 'online' THEN page_id END) AS public_page_used, + COALESCE(SUM(CASE WHEN published_at >= ? THEN view_count ELSE 0 END), 0) AS monthly_view_used + FROM h5_publish_records WHERE user_id = ?`, + [Date.now() - 30 * 24 * 60 * 60 * 1e3, userId] + ); + const publicPageLimit = await resolvePublicPageLimit(); + let schedule = null; + if (scheduleService2) { + try { + const [todayTodoItems, digestSubscriptions] = await Promise.all([ + typeof scheduleService2.listTodayTodoItems === "function" ? scheduleService2.listTodayTodoItems({ userId }) : Promise.resolve([]), + typeof scheduleService2.listDigestSubscriptions === "function" ? scheduleService2.listDigestSubscriptions({ + userId, + digestType: "todo_day", + status: ["active", "locked"], + limit: 10 + }) : Promise.resolve([]) + ]); + schedule = { todayTodoItems, digestSubscriptions }; + } catch { + schedule = null; + } + } + return { + id: row.id, + userId: row.user_id, + name: row.space_name, + status: row.status, + quota: { + quotaBytes, + usedBytes, + reservedBytes, + availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes), + maxFileBytes, + publicPageLimit, + publicPageUsed: asNumber(publicationUsage[0]?.public_page_used), + aiDailyLimit, + aiDailyUsed: 0, + monthlyViewLimit, + monthlyViewUsed: asNumber(publicationUsage[0]?.monthly_view_used) + }, + categories: categories.map(categoryResponse), + createdAt: asNumber(row.created_at), + updatedAt: asNumber(row.updated_at), + schedule + }; + }; + const getQuota = async (userId) => { + const space = await getSpace(userId); + return space?.quota ?? null; + }; + const listCategories = async (userId) => { + const space = await getSpace(userId); + return space?.categories ?? null; + }; + return { + getSpace, + getQuota, + listCategories + }; +} + +// db.mjs +var __dirname = path.dirname(fileURLToPath(import.meta.url)); +function isDatabaseConfigured() { + return Boolean( + process.env.DATABASE_URL || process.env.MYSQL_HOST && process.env.MYSQL_DATABASE + ); +} +function createDbPool() { + if (!isDatabaseConfigured()) { + throw new Error("MySQL \u672A\u914D\u7F6E\uFF0C\u8BF7\u8BBE\u7F6E DATABASE_URL \u6216 MYSQL_* \u73AF\u5883\u53D8\u91CF"); + } + if (process.env.DATABASE_URL) { + return mysql.createPool(process.env.DATABASE_URL); + } + return mysql.createPool({ + host: process.env.MYSQL_HOST ?? "localhost", + port: Number(process.env.MYSQL_PORT ?? 3306), + user: process.env.MYSQL_USER ?? "boot", + password: process.env.MYSQL_PASSWORD ?? "", + database: process.env.MYSQL_DATABASE ?? "tkmind", + waitForConnections: true, + connectionLimit: 10 + }); +} +async function columnExists(pool, table, column) { + const [rows] = await pool.query( + `SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + LIMIT 1`, + [table, column] + ); + return rows.length > 0; +} +async function indexExists(pool, table, index) { + const [rows] = await pool.query( + `SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + LIMIT 1`, + [table, index] + ); + return rows.length > 0; +} +async function foreignKeyDeleteRule(pool, table, constraint) { + const [rows] = await pool.query( + `SELECT DELETE_RULE + FROM information_schema.REFERENTIAL_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + LIMIT 1`, + [table, constraint] + ); + return rows[0]?.DELETE_RULE ?? null; +} +async function ensureForeignKeyDeleteRule(pool, { table, constraint, column, referencedTable, referencedColumn = "id", deleteRule }) { + const currentRule = await foreignKeyDeleteRule(pool, table, constraint); + if (currentRule === deleteRule) return; + if (currentRule) { + await pool.query(`ALTER TABLE \`${table}\` DROP FOREIGN KEY \`${constraint}\``); + } + await pool.query( + `ALTER TABLE \`${table}\` + ADD CONSTRAINT \`${constraint}\` + FOREIGN KEY (\`${column}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`) + ON DELETE ${deleteRule}` + ); +} +async function migrateSchema(pool) { + const renames = [ + ["h5_user_sessions", "goose_session_id", "agent_session_id"], + ["h5_session_billing_state", "goose_session_id", "agent_session_id"], + ["h5_usage_records", "goose_session_id", "agent_session_id"] + ]; + for (const [table, oldCol, newCol] of renames) { + if (await columnExists(pool, table, oldCol)) { + await pool.query( + `ALTER TABLE \`${table}\` CHANGE \`${oldCol}\` \`${newCol}\` VARCHAR(128) NOT NULL` + ); + } + } + const userColumns = [ + ["slug", "VARCHAR(64) NULL AFTER username"], + ["email", "VARCHAR(255) NULL AFTER slug"], + [ + "password_algorithm", + "VARCHAR(32) NOT NULL DEFAULT 'pbkdf2-sha512' AFTER password_hash" + ], + ["plan_type", "VARCHAR(32) NOT NULL DEFAULT 'free' AFTER status"] + ]; + for (const [column, definition] of userColumns) { + if (!await columnExists(pool, "h5_users", column)) { + await pool.query(`ALTER TABLE h5_users ADD COLUMN \`${column}\` ${definition}`); + } + } + await pool.query(`UPDATE h5_users SET slug = username WHERE slug IS NULL OR slug = ''`); + if (!await indexExists(pool, "h5_users", "uq_h5_users_slug")) { + await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_slug (slug)`); + } + if (!await indexExists(pool, "h5_users", "uq_h5_users_email")) { + await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_email (email)`); + } + const assetForeignKeys = [ + { + table: "h5_assets", + constraint: "fk_h5_asset_category", + column: "category_id", + referencedTable: "h5_space_categories", + deleteRule: "CASCADE" + }, + { + table: "h5_assets", + constraint: "fk_h5_asset_parent", + column: "parent_id", + referencedTable: "h5_assets", + deleteRule: "SET NULL" + }, + { + table: "h5_asset_versions", + constraint: "fk_h5_asset_version_user", + column: "created_by", + referencedTable: "h5_users", + deleteRule: "CASCADE" + }, + { + table: "h5_upload_sessions", + constraint: "fk_h5_upload_category", + column: "category_id", + referencedTable: "h5_space_categories", + deleteRule: "CASCADE" + }, + { + table: "h5_page_versions", + constraint: "fk_h5_page_version_content", + column: "content_asset_id", + referencedTable: "h5_assets", + deleteRule: "CASCADE" + } + ]; + for (const foreignKey of assetForeignKeys) { + await ensureForeignKeyDeleteRule(pool, foreignKey); + } + await pool.query( + `ALTER TABLE h5_assets + MODIFY source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace') + NOT NULL DEFAULT 'upload'` + ); + const llmColumns = [ + ["provider_kind", "ENUM('builtin','custom') NOT NULL DEFAULT 'builtin' AFTER provider_id"], + ["api_url", "VARCHAR(512) NULL AFTER provider_kind"], + ["base_path", "VARCHAR(256) NULL AFTER api_url"], + ["models_json", "TEXT NULL AFTER base_path"], + ["goosed_provider_id", "VARCHAR(64) NULL AFTER models_json"], + ["engine", "VARCHAR(32) NOT NULL DEFAULT 'openai' AFTER goosed_provider_id"], + ["relay_provider", "VARCHAR(64) NULL AFTER engine"], + ["is_vision_selected", "TINYINT(1) NOT NULL DEFAULT 0 AFTER is_selected"] + ]; + for (const [column, definition] of llmColumns) { + if (!await columnExists(pool, "h5_llm_provider_keys", column)) { + await pool.query(`ALTER TABLE h5_llm_provider_keys ADD COLUMN \`${column}\` ${definition}`); + } + } + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( + id CHAR(36) PRIMARY KEY, + executor ENUM('goose', 'aider', 'openhands') NOT NULL, + purpose VARCHAR(32) NOT NULL DEFAULT 'default', + provider_key_id CHAR(36) NULL, + model VARCHAR(128) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_llm_executor_binding (executor, purpose), + KEY idx_h5_llm_executor_provider (provider_key_id), + CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + const publishColumns = [ + ["plaza_view_count", "BIGINT NOT NULL DEFAULT 0"], + ["plaza_like_count", "BIGINT NOT NULL DEFAULT 0"] + ]; + for (const [column, definition] of publishColumns) { + if (!await columnExists(pool, "h5_publish_records", column)) { + await pool.query(`ALTER TABLE h5_publish_records ADD COLUMN \`${column}\` ${definition}`); + } + } + const plazaUserColumns = [ + ["plaza_post_count", "INT UNSIGNED NOT NULL DEFAULT 0"], + ["plaza_follower_count", "INT UNSIGNED NOT NULL DEFAULT 0"], + ["plaza_following_count", "INT UNSIGNED NOT NULL DEFAULT 0"], + ["plaza_verified", "TINYINT(1) NOT NULL DEFAULT 0"], + ["plaza_post_banned", "TINYINT(1) NOT NULL DEFAULT 0"], + ["plaza_comment_banned", "TINYINT(1) NOT NULL DEFAULT 0"], + [ + "ops_role", + "ENUM('none','reviewer','editor','ops_admin') NOT NULL DEFAULT 'none'" + ] + ]; + for (const [column, definition] of plazaUserColumns) { + if (!await columnExists(pool, "h5_users", column)) { + await pool.query(`ALTER TABLE h5_users ADD COLUMN \`${column}\` ${definition}`); + } + } + if (!await columnExists(pool, "h5_users", "signup_source")) { + await pool.query( + `ALTER TABLE h5_users ADD COLUMN signup_source VARCHAR(32) NULL DEFAULT 'password' AFTER workspace_root` + ); + } + if (!await columnExists(pool, "h5_user_sessions", "goosed_node")) { + await pool.query( + `ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0` + ); + } + const oauthStateColumns = [ + ["intent", "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"], + ["bind_user_id", "CHAR(36) NULL AFTER intent"], + ["auth_mode", "VARCHAR(8) NOT NULL DEFAULT 'mp' AFTER bind_user_id"], + ["status", "VARCHAR(16) NOT NULL DEFAULT 'pending' AFTER auth_mode"], + ["result_kind", "VARCHAR(32) NULL AFTER status"], + ["result_token", "VARCHAR(512) NULL AFTER result_kind"], + ["result_message", "VARCHAR(255) NULL AFTER result_token"] + ]; + for (const [column, definition] of oauthStateColumns) { + if (!await columnExists(pool, "h5_wechat_oauth_states", column)) { + await pool.query( + `ALTER TABLE h5_wechat_oauth_states ADD COLUMN \`${column}\` ${definition}` + ); + } + } + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds ( + token VARCHAR(64) PRIMARY KEY, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + unionid VARCHAR(64) NULL, + nickname VARCHAR(128) NULL, + avatar_url VARCHAR(512) NULL, + return_to VARCHAR(512) NULL, + utm_source VARCHAR(64) NULL, + utm_medium VARCHAR(64) NULL, + utm_campaign VARCHAR(64) NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_pending_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_wechat_mp_messages ( + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + msg_id VARCHAR(128) NOT NULL, + status ENUM('processing', 'done', 'failed') NOT NULL DEFAULT 'processing', + agent_session_id VARCHAR(128) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (app_id, openid, msg_id), + KEY idx_wechat_mp_messages_updated (updated_at), + KEY idx_wechat_mp_messages_session (agent_session_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_wechat_mp_message_details ( + id CHAR(36) PRIMARY KEY, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + user_id CHAR(36) NULL, + msg_id VARCHAR(128) NULL, + msg_type VARCHAR(32) NOT NULL, + display_text TEXT NULL, + agent_text TEXT NULL, + media_id VARCHAR(256) NULL, + media_url TEXT NULL, + media_public_url TEXT NULL, + media_format VARCHAR(64) NULL, + location_lat DECIMAL(10,7) NULL, + location_lng DECIMAL(10,7) NULL, + location_label VARCHAR(255) NULL, + link_url TEXT NULL, + link_title VARCHAR(255) NULL, + raw_xml_hash CHAR(40) NULL, + raw_json JSON NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_mp_message_details_user (app_id, openid, created_at), + KEY idx_wechat_mp_message_details_msg (msg_id), + KEY idx_wechat_mp_message_details_type (msg_type, created_at), + CONSTRAINT fk_wechat_mp_message_details_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_items ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + kind ENUM('task', 'event') NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + status ENUM('active', 'completed', 'cancelled', 'deleted') NOT NULL DEFAULT 'active', + start_at BIGINT NULL, + end_at BIGINT NULL, + due_at BIGINT NULL, + all_day TINYINT(1) NOT NULL DEFAULT 0, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + location VARCHAR(255) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + metadata_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT NULL, + KEY idx_schedule_user_time (user_id, status, start_at, due_at), + KEY idx_schedule_user_updated (user_id, updated_at), + CONSTRAINT fk_schedule_item_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_reminders ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + item_id CHAR(36) NOT NULL, + remind_at BIGINT NOT NULL, + offset_minutes INT NULL, + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('pending', 'locked', 'sent', 'failed', 'cancelled') NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + last_error VARCHAR(500) NULL, + locked_until BIGINT NULL, + sent_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_schedule_item_remind_at (item_id, remind_at, channel), + KEY idx_reminder_due (status, remind_at), + KEY idx_reminder_user (user_id, status, remind_at), + CONSTRAINT fk_schedule_reminder_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_reminder_item FOREIGN KEY (item_id) REFERENCES h5_schedule_items(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_digest_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + digest_type ENUM('todo_day') NOT NULL DEFAULT 'todo_day', + hour TINYINT UNSIGNED NOT NULL, + minute TINYINT UNSIGNED NOT NULL DEFAULT 0, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NOT NULL, + last_run_at BIGINT NULL, + attempts INT NOT NULL DEFAULT 0, + locked_until BIGINT NULL, + last_error VARCHAR(500) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_schedule_digest_user_type_channel (user_id, digest_type, channel), + KEY idx_schedule_digest_due (status, next_run_at), + CONSTRAINT fk_schedule_digest_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_balance_alert_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + threshold_cents BIGINT NOT NULL, + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NOT NULL, + last_run_at BIGINT NULL, + last_notified_balance_cents BIGINT NULL, + attempts INT NOT NULL DEFAULT 0, + locked_until BIGINT NULL, + last_error VARCHAR(500) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_balance_alert_user_channel (user_id, channel), + KEY idx_balance_alert_due (status, next_run_at), + KEY idx_balance_alert_user (user_id, status, next_run_at), + CONSTRAINT fk_balance_alert_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( + id CHAR(36) PRIMARY KEY, + reminder_id CHAR(36) NULL, + subscription_id CHAR(36) NULL, + user_id CHAR(36) NOT NULL, + channel ENUM('wechat', 'in_app') NOT NULL, + status ENUM('success', 'failed') NOT NULL, + provider_message_id VARCHAR(128) NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(500) NULL, + created_at BIGINT NOT NULL, + KEY idx_delivery_reminder (reminder_id, created_at), + KEY idx_delivery_subscription (subscription_id, created_at), + KEY idx_delivery_user (user_id, created_at), + CONSTRAINT fk_schedule_delivery_reminder FOREIGN KEY (reminder_id) REFERENCES h5_schedule_reminders(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_delivery_subscription FOREIGN KEY (subscription_id) REFERENCES h5_schedule_digest_subscriptions(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_delivery_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_user_notifications ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + channel ENUM('web', 'wechat') NOT NULL DEFAULT 'web', + notification_type VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + data_json JSON NULL, + status ENUM('unread', 'read') NOT NULL DEFAULT 'unread', + read_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_user_notifications_user_status_created (user_id, status, created_at), + KEY idx_user_notifications_user_created (user_id, created_at), + CONSTRAINT fk_user_notifications_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query( + `ALTER TABLE h5_payment_orders + MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'` + ); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_session_snapshots ( + agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, + user_id CHAR(36) NOT NULL, + name VARCHAR(512) NOT NULL DEFAULT '', + working_dir VARCHAR(1024) NOT NULL DEFAULT '', + created_at_str VARCHAR(64) NOT NULL DEFAULT '', + updated_at_str VARCHAR(64) NOT NULL DEFAULT '', + user_set_name TINYINT(1) NOT NULL DEFAULT 0, + recipe_json TEXT NULL, + synced_msg_count INT NOT NULL DEFAULT 0, + source_updated_at VARCHAR(64) NOT NULL DEFAULT '', + messages_json LONGTEXT NOT NULL, + synced_at BIGINT NOT NULL, + KEY idx_h5_snapshots_user (user_id), + KEY idx_h5_snapshots_synced (synced_at), + CONSTRAINT fk_h5_snapshot_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + plan_type VARCHAR(32) NOT NULL DEFAULT 'free', + status ENUM('active', 'expired', 'cancelled') NOT NULL DEFAULT 'active', + period_tokens_limit BIGINT NOT NULL DEFAULT 0, + period_tokens_used BIGINT NOT NULL DEFAULT 0, + period_start BIGINT NOT NULL, + period_end BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00, + operator_id CHAR(36) NULL, + note VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_sub_user_status (user_id, status), + KEY idx_h5_sub_expires (expires_at, status), + CONSTRAINT fk_h5_sub_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, + period_tokens_limit, period_tokens_used, + period_start, period_end, expires_at, overage_rate, + operator_id, note, created_at, updated_at) + SELECT + UUID(), + u.id, + 'free', + 'active', + 150000, 0, + UNIX_TIMESTAMP() * 1000, + (UNIX_TIMESTAMP() + 30 * 86400) * 1000, + (UNIX_TIMESTAMP() + 30 * 86400) * 1000, + 1.00, + NULL, + '\u7CFB\u7EDF\u8FC1\u79FB\u8865\u5EFA\u514D\u8D39\u5957\u9910', + UNIX_TIMESTAMP() * 1000, + UNIX_TIMESTAMP() * 1000 + FROM h5_users u + WHERE NOT EXISTS ( + SELECT 1 FROM h5_subscriptions s + WHERE s.user_id = u.id AND s.status = 'active' + ) + `); +} +async function initSchema(pool) { + const schemaPath = path.join(__dirname, "schema.sql"); + const sql = fs2.readFileSync(schemaPath, "utf8"); + for (const statement of sql.split(";")) { + const trimmed = statement.trim(); + if (trimmed) { + await pool.query(trimmed); + } + } + await migrateSchema(pool); + await ensureDefaultSpaces(pool, { + quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024) + }); +} + +// tkmind-proxy.mjs +import { Readable } from "node:stream"; +import { Agent, fetch as undiciFetch } from "undici"; + +// sse-billing.mjs +import { Transform } from "node:stream"; +function parseSseChunk(chunk) { + const events = []; + const blocks = chunk.split("\n\n"); + for (const block of blocks) { + if (!block.trim()) continue; + let data; + for (const line of block.split("\n")) { + if (line.startsWith("data:")) { + data = line.slice(5).trim(); + } + } + if (!data) continue; + try { + events.push(JSON.parse(data)); + } catch { + } + } + return events; +} +function createSseBillingTransform({ onFinish }) { + let buffer = ""; + return new Transform({ + transform(chunk, _encoding, callback) { + buffer += chunk.toString("utf8"); + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + for (const block of parts) { + if (!block.trim()) continue; + let dataLine; + for (const line of block.split("\n")) { + if (line.startsWith("data:")) dataLine = line.slice(5).trim(); + } + if (!dataLine) continue; + try { + const event = JSON.parse(dataLine); + if (event?.type === "Finish" && event.token_state) { + void onFinish(event).catch(() => { + }); + } + } catch { + } + } + callback(null, chunk); + }, + flush(callback) { + if (buffer.trim()) { + for (const event of parseSseChunk(`${buffer} + +`)) { + if (event?.type === "Finish" && event.token_state) { + void onFinish(event).catch(() => { + }); + } + } + } + callback(); + } + }); +} +function appendBalanceEvent(payload) { + const data = typeof payload === "number" ? { balanceCents: payload } : payload && typeof payload === "object" ? payload : { balanceCents: 0 }; + return `event: balance +data: ${JSON.stringify(data)} + +`; +} + +// capabilities.mjs +import path2 from "node:path"; +import { fileURLToPath as fileURLToPath2 } from "node:url"; + +// policies.mjs +var POLICY_CATALOG = [ + { + key: "goose_mode", + label: "TKMind \u6A21\u5F0F", + description: "H5 \u7EC8\u7AEF\u7528\u6237\u4E0D\u5728\u804A\u5929\u91CC\u70B9\u300C\u5141\u8BB8/\u62D2\u7EDD\u300D\uFF1B\u53EF\u7528\u5DE5\u5177\u7531\u300C\u80FD\u529B\u6743\u9650\u300D\u51B3\u5B9A\u3002chat \u4E0D\u8C03\u7528\u5DE5\u5177\uFF0Cauto \u5728\u6388\u6743\u8303\u56F4\u5185\u81EA\u52A8\u6267\u884C\u3002", + type: "select", + options: [ + { value: "chat", label: "\u4EC5\u804A\u5929 (chat)" }, + { value: "auto", label: "\u81EA\u52A8\u6267\u884C (auto)" } + ], + defaultValue: "chat", + category: "goose", + risk: "high" + }, + { + key: "workspace_access", + label: "\u5DE5\u4F5C\u533A\u8BBF\u95EE", + description: "readonly \u65F6\u7981\u6B62 shell \u4E0E\u6587\u4EF6\u5199\u5165\uFF0C\u4EC5\u5141\u8BB8\u6D4F\u89C8\u4E0E\u5206\u6790", + type: "select", + options: [ + { value: "readwrite", label: "\u8BFB\u5199" }, + { value: "readonly", label: "\u53EA\u8BFB" } + ], + defaultValue: "readwrite", + category: "workspace", + risk: "medium" + }, + { + key: "network_egress", + label: "\u7F51\u7EDC\u51FA\u7AD9", + description: "deny \u65F6\u7981\u7528 shell\u3001\u7535\u8111\u63A7\u5236\u3001\u6C99\u7BB1\u811A\u672C\u7B49\u53EF\u80FD\u8BBF\u95EE\u7F51\u7EDC\u7684\u6269\u5C55", + type: "select", + options: [ + { value: "deny", label: "\u7981\u6B62\uFF08\u63A8\u8350\uFF09" }, + { value: "allow", label: "\u5141\u8BB8\uFF08\u9700\u540C\u65F6\u5F00\u542F\u5BF9\u5E94\u80FD\u529B\uFF09" } + ], + defaultValue: "deny", + category: "network", + risk: "high" + }, + { + key: "api_lockdown", + label: "API \u4EE3\u7406\u9501\u5B9A", + description: "\u5F00\u542F\u540E\u4EC5\u5141\u8BB8\u804A\u5929\u76F8\u5173 API\uFF0C\u62E6\u622A\u6539\u6A21\u5F0F\u3001\u52A0\u6269\u5C55\u3001\u5199\u914D\u7F6E\u7B49\u5371\u9669\u8BF7\u6C42", + type: "boolean", + defaultValue: true, + category: "proxy", + risk: "medium" + }, + { + key: "code_delegate_executor", + label: "\u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668", + description: "\u63A7\u5236 Goose \u5728\u591A\u6587\u4EF6\u7F16\u7801\u3001\u4FEE\u590D\u4E0E\u91CD\u6784\u4EFB\u52A1\u4E2D\u4F18\u5148\u59D4\u6258\u7ED9\u8C01\u3002auto \u7531 Goose \u7ED3\u5408\u5F53\u524D\u53EF\u7528\u6269\u5C55\u81EA\u884C\u5224\u65AD\uFF1Baider / openhands \u5219\u4F18\u5148\u4F7F\u7528\u6307\u5B9A\u6267\u884C\u5668\u3002", + type: "select", + options: [ + { value: "auto", label: "\u81EA\u52A8\u9009\u62E9 (auto)" }, + { value: "aider", label: "\u4F18\u5148 Aider" }, + { value: "openhands", label: "\u4F18\u5148 OpenHands" } + ], + defaultValue: "auto", + category: "routing", + risk: "medium" + }, + { + key: "code_task_routing", + label: "\u4EE3\u7801\u4EFB\u52A1\u8DEF\u7531\u7B56\u7565", + description: "\u5B9A\u4E49\u4EE3\u7801\u4EFB\u52A1\u5982\u4F55\u5728 Aider \u4E0E OpenHands \u4E4B\u95F4\u5206\u6D41\u3002balanced \u7531 Goose \u6309\u4EFB\u52A1\u590D\u6742\u5EA6\u5224\u65AD\uFF1Bsplit \u5EFA\u8BAE\u5C0F\u6539\u52A8\u8D70 Aider\u3001\u590D\u6742\u4EFB\u52A1\u8D70 OpenHands\uFF1Bforce_* \u5219\u5F3A\u5236\u4F18\u5148\u5355\u4E00\u8DEF\u5F84\u3002", + type: "select", + options: [ + { value: "balanced", label: "\u5E73\u8861\u8DEF\u7531 (balanced)" }, + { value: "split", label: "\u5C0F\u6539\u52A8 Aider\uFF0C\u590D\u6742\u4EFB\u52A1 OpenHands" }, + { value: "force_aider", label: "\u5C3D\u91CF\u7EDF\u4E00\u8D70 Aider" }, + { value: "force_openhands", label: "\u5C3D\u91CF\u7EDF\u4E00\u8D70 OpenHands" } + ], + defaultValue: "balanced", + category: "routing", + risk: "medium" + } +]; +var DEFAULT_USER_POLICIES = Object.fromEntries( + POLICY_CATALOG.map((item) => [item.key, item.defaultValue]) +); +var POLICY_KEYS = new Set(POLICY_CATALOG.map((item) => item.key)); +var GOOSE_MODES = /* @__PURE__ */ new Set(["auto", "approve", "smart_approve", "chat"]); +var WORKSPACE_ACCESS = /* @__PURE__ */ new Set(["readwrite", "readonly"]); +var NETWORK_EGRESS = /* @__PURE__ */ new Set(["allow", "deny"]); +var CODE_DELEGATE_EXECUTORS = /* @__PURE__ */ new Set(["auto", "aider", "openhands"]); +var CODE_TASK_ROUTINGS = /* @__PURE__ */ new Set(["balanced", "split", "force_aider", "force_openhands"]); +function policyKeys() { + return [...POLICY_KEYS]; +} +function isValidPolicyKey(key) { + return POLICY_KEYS.has(key); +} +function normalizePolicyPatch(patch) { + const normalized = {}; + for (const [key, raw] of Object.entries(patch ?? {})) { + if (!isValidPolicyKey(key)) continue; + const def = POLICY_CATALOG.find((item) => item.key === key); + if (!def) continue; + if (def.type === "boolean") { + normalized[key] = raw === true || raw === "true" || raw === 1 || raw === "1"; + continue; + } + const value = String(raw ?? "").trim(); + if (def.key === "goose_mode" && GOOSE_MODES.has(value)) normalized[key] = value; + if (def.key === "workspace_access" && WORKSPACE_ACCESS.has(value)) normalized[key] = value; + if (def.key === "network_egress" && NETWORK_EGRESS.has(value)) normalized[key] = value; + if (def.key === "code_delegate_executor" && CODE_DELEGATE_EXECUTORS.has(value)) { + normalized[key] = value; + } + if (def.key === "code_task_routing" && CODE_TASK_ROUTINGS.has(value)) { + normalized[key] = value; + } + } + return normalized; +} +function resolvePolicies(rolePolicies, userOverrides) { + const resolved = { ...DEFAULT_USER_POLICIES }; + for (const [key, value] of Object.entries(rolePolicies ?? {})) { + if (isValidPolicyKey(key)) resolved[key] = value; + } + for (const [key, value] of Object.entries(userOverrides ?? {})) { + if (isValidPolicyKey(key)) resolved[key] = value; + } + if (resolved.goose_mode === "approve" || resolved.goose_mode === "smart_approve") { + resolved.goose_mode = "auto"; + } + return resolved; +} +function hasExecutableTools(capabilities) { + return Boolean( + capabilities?.static_publish || capabilities?.shell || capabilities?.filesystem || capabilities?.code_browse || capabilities?.subagent || capabilities?.code_sandbox || capabilities?.computer || capabilities?.charts || capabilities?.aider || capabilities?.openhands || capabilities?.apps || capabilities?.todo || capabilities?.skills || capabilities?.chat_recall + ); +} +function resolveAgentGooseMode(capabilities, policies) { + const requested = policies?.goose_mode ?? DEFAULT_USER_POLICIES.goose_mode; + if (!hasExecutableTools(capabilities)) { + return "chat"; + } + if (requested === "chat" || requested === "approve" || requested === "smart_approve") { + return "auto"; + } + return "auto"; +} +function applyPoliciesToCapabilities(capabilities, policies) { + const effective = { ...capabilities }; + if (policies.workspace_access === "readonly") { + effective.shell = false; + effective.filesystem = false; + effective.static_publish = false; + effective.aider = false; + effective.openhands = false; + effective.code_sandbox = false; + effective.image_read = false; + } + if (policies.network_egress === "deny") { + if (!effective.static_publish) { + effective.shell = false; + } + effective.computer = false; + effective.code_sandbox = false; + } + return effective; +} +var USER_API_ALLOWLIST = [ + { method: "GET", pattern: /^\/status$/ }, + { method: "POST", pattern: /^\/agent\/start$/ }, + { method: "POST", pattern: /^\/agent\/resume$/ }, + { method: "GET", pattern: /^\/sessions$/ }, + { method: "GET", pattern: /^\/sessions\/[^/]+$/ }, + { method: "DELETE", pattern: /^\/sessions\/[^/]+$/ }, + { method: "GET", pattern: /^\/sessions\/[^/]+\/events$/ }, + { method: "POST", pattern: /^\/sessions\/[^/]+\/reply$/ }, + { method: "POST", pattern: /^\/sessions\/[^/]+\/cancel$/ }, + { method: "POST", pattern: /^\/action-required\/tool-confirmation$/ }, + { method: "POST", pattern: /^\/agent\/update_provider$/ }, + { method: "POST", pattern: /^\/config\/read$/ }, + { method: "POST", pattern: /^\/agent\/harness_bootstrap$/ }, + { method: "POST", pattern: /^\/agent\/harness_remember$/ } +]; +function isNativeH5ApiPath(pathname) { + const path23 = String(pathname ?? ""); + return path23.startsWith("/mindspace/") || path23.startsWith("/plaza/"); +} +function evaluateProxyRequest(method, pathname, policies, { unrestricted = false } = {}) { + if (unrestricted) return { allowed: true }; + if (!policies.api_lockdown) return { allowed: true }; + if (isNativeH5ApiPath(pathname)) { + return { + allowed: false, + reason: `H5 \u672C\u5730\u63A5\u53E3\u4E0D\u5E94\u8D70 Agent \u4EE3\u7406\uFF1A${method.toUpperCase()} ${pathname}` + }; + } + const upper = method.toUpperCase(); + const allowed = USER_API_ALLOWLIST.some( + (rule) => rule.method === upper && rule.pattern.test(pathname) + ); + if (allowed) return { allowed: true }; + return { + allowed: false, + reason: `\u7B56\u7565\u5DF2\u9501\u5B9A API\uFF1A${upper} ${pathname}` + }; +} + +// capabilities.mjs +function resolveSandboxMcpServerPath() { + return path2.join(path2.dirname(fileURLToPath2(import.meta.url)), "mindspace-sandbox-mcp.mjs"); +} +var CAPABILITY_CATALOG = [ + { + key: "shell", + label: "Shell \u547D\u4EE4", + description: "\u6267\u884C bash/shell \u547D\u4EE4\uFF08developer.shell\uFF09", + risk: "high", + category: "developer" + }, + { + key: "static_publish", + label: "\u7528\u6237\u6C99\u7BB1\u76EE\u5F55", + description: "\u5728 MindSpace/<\u7528\u6237ID>/ \u5185\u53EF\u4F7F\u7528 sandbox-fs \u7684 write_file/edit_file/read_file/create_dir\uFF08\u4EE5\u53CA\u6309\u6743\u9650\u5F00\u653E\u7684 list_dir\uFF1B\u4E0D\u53EF\u8D8A\u51FA\u8BE5\u76EE\u5F55\uFF09", + risk: "medium", + category: "publisher" + }, + { + key: "private_data_space", + label: "\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4", + description: "\u4E3A\u7528\u6237\u63D0\u4F9B\u552F\u4E00\u7684\u79C1\u6709 SQLite \u6570\u636E\u7A7A\u95F4\uFF0C\u4F9B Agent \u521B\u5EFA\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u7B49\u79C1\u6709\u7ED3\u6784\u5316\u6570\u636E\u8868", + risk: "medium", + category: "publisher" + }, + { + key: "filesystem", + label: "\u6587\u4EF6\u8BFB\u5199\uFF08\u5168\u76EE\u5F55\uFF09", + description: "\u5728\u5DE5\u4F5C\u533A\u5185\u4EFB\u610F\u8BFB\u5199\u6587\u4EF6\uFF08developer.write / edit\uFF09\uFF0C\u4EC5\u9AD8\u7EA7\u7528\u6237", + risk: "high", + category: "developer" + }, + { + key: "code_browse", + label: "\u4EE3\u7801\u6D4F\u89C8", + description: "\u76EE\u5F55\u6811\u3001\u4EE3\u7801\u7ED3\u6784\u5206\u6790\uFF08developer.tree\u3001analyze\uFF09", + risk: "low", + category: "developer" + }, + { + key: "image_read", + label: "\u56FE\u7247\u8BFB\u53D6", + description: "\u8BFB\u53D6\u672C\u5730\u6216\u7F51\u7EDC\u56FE\u7247\uFF08developer.read_image\uFF09", + risk: "low", + category: "developer" + }, + { + key: "skills", + label: "\u6280\u80FD\u52A0\u8F7D", + description: "\u52A0\u8F7D\u6280\u80FD\u4E0E\u77E5\u8BC6\uFF08skills / summon.load\uFF09", + risk: "low", + category: "knowledge" + }, + { + key: "subagent", + label: "\u5B50\u4EFB\u52A1\u6D3E\u53D1", + description: "delegate \u5B50 Agent \u6267\u884C\u590D\u6742\u4EFB\u52A1\uFF08summon\uFF09", + risk: "high", + category: "agent" + }, + { + key: "code_sandbox", + label: "\u6C99\u7BB1\u811A\u672C", + description: "execute_typescript \u5728\u6C99\u7BB1\u4E2D\u6279\u91CF\u8C03\u7528\u5DE5\u5177\uFF08code_execution\uFF09", + risk: "high", + category: "agent" + }, + { + key: "chat_recall", + label: "\u5BF9\u8BDD\u56DE\u6EAF", + description: "\u68C0\u7D22\u5386\u53F2\u4F1A\u8BDD\uFF08chatrecall\uFF09", + risk: "low", + category: "memory" + }, + { + key: "context_memory", + label: "\u9879\u76EE\u8BB0\u5FC6", + description: "\u4F1A\u8BDD\u7EA7\u9879\u76EE\u8BB0\u5FC6\u6CE8\u5165\uFF08projectmemory\uFF0CH5 \u5F15\u5BFC\u7528\uFF09", + risk: "low", + category: "memory" + }, + { + key: "memory_store", + label: "\u957F\u671F\u8BB0\u5FC6", + description: "\u8BB0\u4F4F/\u68C0\u7D22\u7528\u6237\u504F\u597D\uFF08memory \u6269\u5C55\uFF09", + risk: "medium", + category: "memory" + }, + { + key: "extension_admin", + label: "\u6269\u5C55\u7BA1\u7406", + description: "\u641C\u7D22\u3001\u542F\u7528\u3001\u7981\u7528\u6269\u5C55\uFF08extensionmanager\uFF09", + risk: "high", + category: "admin" + }, + { + key: "apps", + label: "\u5E94\u7528\u7BA1\u7406", + description: "\u521B\u5EFA\u4E0E\u7BA1\u7406 TKMind \u5E94\u7528\uFF08apps\uFF09", + risk: "medium", + category: "agent" + }, + { + key: "todo", + label: "\u5F85\u529E\u4E8B\u9879", + description: "\u4EFB\u52A1\u5217\u8868\u8DDF\u8E2A\uFF08todo\uFF09", + risk: "low", + category: "productivity" + }, + { + key: "computer", + label: "\u7535\u8111\u63A7\u5236", + description: "\u81EA\u52A8\u5316\u811A\u672C\u3001\u7F51\u9875\u6293\u53D6\u3001\u6587\u6863\u5904\u7406\uFF08computercontroller\uFF09", + risk: "high", + category: "automation" + }, + { + key: "charts", + label: "\u6570\u636E\u53EF\u89C6\u5316", + description: "\u56FE\u8868\u4E0E\u53EF\u89C6\u5316\uFF08autovisualiser\uFF09", + risk: "low", + category: "automation" + }, + { + key: "aider", + label: "Aider \u7F16\u7801", + description: "\u591A\u6587\u4EF6\u7F16\u7801\u59D4\u6258\uFF08aider\uFF09", + risk: "high", + category: "developer" + }, + { + key: "openhands", + label: "OpenHands \u7F16\u7801", + description: "\u590D\u6742\u591A\u6587\u4EF6\u7F16\u7801\u4E0E\u4ED3\u5E93\u7EA7\u4EFB\u52A1\u59D4\u6258\uFF08openhands\uFF09", + risk: "high", + category: "developer" + } +]; +var USER_NON_GRANTABLE_CAPABILITIES = /* @__PURE__ */ new Set(["extension_admin"]); +function clampUserCapabilities(capabilities) { + const clamped = { ...capabilities }; + for (const key of USER_NON_GRANTABLE_CAPABILITIES) { + clamped[key] = false; + } + return clamped; +} +var DEFAULT_USER_CAPABILITIES = Object.fromEntries( + CAPABILITY_CATALOG.map(({ key }) => { + const defaults = { + shell: false, + static_publish: false, + private_data_space: true, + filesystem: false, + code_browse: false, + image_read: true, + skills: true, + subagent: false, + code_sandbox: false, + chat_recall: true, + context_memory: true, + memory_store: true, + extension_admin: false, + apps: false, + todo: false, + computer: false, + charts: false, + aider: false, + openhands: false + }; + return [key, defaults[key] ?? false]; + }) +); +var CATALOG_KEYS = new Set(CAPABILITY_CATALOG.map((item) => item.key)); +function catalogKeys() { + return [...CATALOG_KEYS]; +} +function isValidCapabilityKey(key) { + return CATALOG_KEYS.has(key); +} +function makeExtension(type, name, tools = []) { + return { + type, + name, + description: "", + display_name: name, + bundled: true, + available_tools: tools + }; +} +function sandboxDeveloperTools(capabilities) { + const tools = ["write", "edit"]; + if (capabilities.shell) { + tools.push("shell", "tree"); + } else if (capabilities.code_browse) { + tools.push("tree"); + } + if (capabilities.image_read) tools.push("read_image"); + return tools; +} +function sandboxMcpTools(capabilities) { + const tools = []; + if (capabilities.static_publish) { + tools.push("read_file", "write_file", "edit_file", "create_dir"); + if (capabilities.shell || capabilities.code_browse) tools.push("list_dir"); + } + if (capabilities.private_data_space) { + tools.push( + "private_data_info", + "private_data_schema", + "private_data_query", + "private_data_execute", + "schedule_create_item", + "schedule_create_reminder", + "schedule_list_items" + ); + } + return tools; +} +function developerToolsFromPolicy(sessionPolicy) { + const developer = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === "developer"); + const sandboxFs = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === "sandbox-fs"); + return (sandboxFs ?? developer)?.available_tools ?? []; +} +function mergeDeveloperTools(capabilities) { + const tools = []; + if (capabilities.shell) tools.push("shell"); + if (capabilities.filesystem) tools.push("write", "edit"); + if (capabilities.code_browse) tools.push("tree"); + if (capabilities.image_read) tools.push("read_image"); + return tools; +} +function sandboxMcpEnvs(sandboxMcp, mcpTools) { + const envs = { + SANDBOX_ROOT: sandboxMcp.sandboxRoot, + ALLOWED_TOOLS: mcpTools.join(",") + }; + if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; + for (const key of [ + "DATABASE_URL", + "MYSQL_HOST", + "MYSQL_PORT", + "MYSQL_USER", + "MYSQL_PASSWORD", + "MYSQL_DATABASE", + "PRIVATE_DATA_MAX_BYTES" + ]) { + if (process.env[key]) envs[key] = process.env[key]; + } + return envs; +} +function buildAgentExtensionPolicy(capabilities, { unrestricted = false, policies = null, sandboxMcp = null } = {}) { + if (unrestricted) { + return { extensionOverrides: null, enableContextMemory: true, gooseMode: "auto" }; + } + const extensions = []; + if (capabilities.static_publish || capabilities.private_data_space && sandboxMcp) { + if (sandboxMcp?.serverPath && sandboxMcp?.sandboxRoot) { + const mcpTools = sandboxMcpTools(capabilities); + if (mcpTools.length > 0) { + extensions.push({ + type: "stdio", + name: "sandbox-fs", + description: "\u5DE5\u4F5C\u533A\u6C99\u7BB1\u6587\u4EF6\u7CFB\u7EDF\u4E0E\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u662F\u5F53\u524D\u7528\u6237\u552F\u4E00\u7684 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u7528\u4E8E\u8D26\u53F7\u3001\u8BA1\u8D39\u3001\u6743\u9650\u3001\u5BA1\u8BA1\u3001\u516C\u5F00\u5E73\u53F0\u6570\u636E\u6216\u8DE8\u7528\u6237\u6570\u636E\u3002", + display_name: "sandbox-fs", + bundled: false, + cmd: sandboxMcp.nodeExecPath ?? process.execPath, + // sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs + args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot], + // envs (goosed field name) as belt-and-suspenders backup + envs: sandboxMcpEnvs(sandboxMcp, mcpTools), + available_tools: mcpTools + }); + } + if (capabilities.image_read) { + extensions.push(makeExtension("platform", "developer", ["read_image"])); + } + } else if (capabilities.static_publish) { + const sandboxTools = sandboxDeveloperTools(capabilities); + if (sandboxTools.length > 0) { + extensions.push(makeExtension("platform", "developer", sandboxTools)); + } + } + if (capabilities.static_publish) { + extensions.push(makeExtension("platform", "skills", [])); + extensions.push(makeExtension("platform", "summon", ["load_skill"])); + extensions.push(makeExtension("platform", "projectmemory", [])); + } + } else { + const developerTools = mergeDeveloperTools(capabilities); + if (developerTools.length > 0) { + extensions.push(makeExtension("platform", "developer", developerTools)); + } + if (capabilities.code_browse) { + extensions.push(makeExtension("platform", "analyze", [])); + } + if (capabilities.skills) { + extensions.push(makeExtension("platform", "skills", [])); + } + if (capabilities.skills || capabilities.subagent) { + const summonTools = []; + if (capabilities.skills) summonTools.push("load", "load_skill"); + if (capabilities.subagent) summonTools.push("delegate"); + extensions.push(makeExtension("platform", "summon", summonTools)); + } + } + if (capabilities.code_sandbox) { + extensions.push(makeExtension("platform", "code_execution", [])); + } + if (capabilities.chat_recall) { + extensions.push(makeExtension("platform", "chatrecall", [])); + } + if (capabilities.context_memory) { + extensions.push(makeExtension("platform", "projectmemory", [])); + } + if (capabilities.memory_store) { + extensions.push(makeExtension("builtin", "memory", [])); + } + if (capabilities.extension_admin) { + extensions.push(makeExtension("platform", "extensionmanager", [])); + } + if (capabilities.apps) { + extensions.push(makeExtension("platform", "apps", [])); + } + if (capabilities.todo) { + extensions.push(makeExtension("platform", "todo", [])); + } + if (capabilities.computer) { + extensions.push(makeExtension("builtin", "computercontroller", [])); + } + if (capabilities.charts) { + extensions.push(makeExtension("builtin", "autovisualiser", [])); + } + if (capabilities.aider) { + extensions.push(makeExtension("platform", "aider", [])); + } + if (capabilities.openhands) { + extensions.push(makeExtension("platform", "openhands", [])); + } + return { + extensionOverrides: extensions, + enableContextMemory: Boolean( + capabilities.context_memory || capabilities.chat_recall || capabilities.static_publish + ), + gooseMode: resolveAgentGooseMode(capabilities, policies) + }; +} +function buildPageEditAgentPolicy(basePolicy) { + if (basePolicy?.unrestricted) { + return { + ...basePolicy, + enableContextMemory: false, + gooseMode: "auto" + }; + } + const baseDeveloper = basePolicy?.extensionOverrides?.find((ext) => ext.name === "developer"); + const canShell = baseDeveloper?.available_tools?.includes("shell") || basePolicy?.capabilities?.shell === true; + const extensions = canShell ? [makeExtension("platform", "developer", ["shell"])] : []; + return { + ...basePolicy, + extensionOverrides: extensions, + enableContextMemory: false, + gooseMode: "auto" + }; +} +function normalizeCapabilityPatch(patch) { + const normalized = {}; + for (const [key, value] of Object.entries(patch ?? {})) { + if (!isValidCapabilityKey(key)) continue; + normalized[key] = Boolean(value); + } + return normalized; +} + +// user-publish.mjs +import fs3 from "node:fs"; +import path3 from "node:path"; +import { fileURLToPath as fileURLToPath3 } from "node:url"; +var __dirname2 = path3.dirname(fileURLToPath3(import.meta.url)); +var PUBLISH_SKILL_NAME = "static-page-publish"; +var PUBLISH_ROOT_DIR = "MindSpace"; +var PUBLIC_ZONE_DIR = "public"; +var PUBLISH_SKILL_DIR = path3.join(__dirname2, "skills", PUBLISH_SKILL_NAME); +var WORKSPACE_HINTS_FILENAME = ".tkmindhints"; +var LEGACY_WORKSPACE_HINTS_FILENAME = ".goosehints"; +function renderBrandingBlock(userAddressName) { + const name = userAddressName || "\u7528\u6237"; + return `## \u54C1\u724C\u4E0E\u79F0\u547C\uFF08\u786C\u6027\uFF09 + +- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed +- \u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\uFF0C\u7528 **${name}** \u79F0\u547C\u7528\u6237\uFF08\u53EF\u8F85\u4EE5\u300C\u4F60/\u60A8\u300D\uFF09\uFF0C**\u7981\u6B62**\u628A\u7528\u6237\u53EB\u4F5C TKMind +- \u95EE\u5019\u793A\u4F8B\uFF1A\u300C${name}\uFF0C\u4E0B\u5348\u597D\u300D\u2014\u2014\u4E0D\u8981\u7528\u300CTKMind\uFF0C\u4E0B\u5348\u597D\u300D +- \u4E0D\u8981\u63CF\u8FF0\u672C\u5DE5\u4F5C\u533A\u4E3A\u300CRust goose \u9879\u76EE\u300D\u6216\u300Cgoose AI \u6846\u67B6\u300D +- \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210 +`; +} +var INTERNAL_ID_PATTERN = /^wx_[a-z0-9_]{4,64}$|^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +function resolveUserAddressName({ displayName, username, slug } = {}) { + const preferred = String(displayName ?? "").trim(); + if (preferred && !INTERNAL_ID_PATTERN.test(preferred)) return preferred; + const uname = String(username ?? "").trim(); + if (uname && !INTERNAL_ID_PATTERN.test(uname)) return uname; + const fallback = String(slug ?? "").trim(); + if (fallback && !INTERNAL_ID_PATTERN.test(fallback)) return fallback; + return "\u7528\u6237"; +} +function resolvePublicBaseUrl(env = process.env) { + return (env.H5_PUBLIC_BASE_URL ?? "https://m.tkmind.cn").replace(/\/$/, ""); +} +var PUBLISH_KEY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +function resolvePublishKey(user) { + if (!user?.id) throw new Error("\u7F3A\u5C11\u7528\u6237 ID"); + return String(user.id).trim().toLowerCase(); +} +function resolveUsernameSlug(user) { + if (!user?.username) throw new Error("\u7F3A\u5C11\u7528\u6237\u540D"); + return String(user.username).trim().toLowerCase(); +} +function resolvePublishDir(h5Root, user) { + return path3.join(h5Root, PUBLISH_ROOT_DIR, resolvePublishKey(user)); +} +function resolveLegacyPublishDir(h5Root, user) { + if (!user?.username) return null; + return path3.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user)); +} +function mergePublishTrees(sourceDir, targetDir) { + if (!fs3.existsSync(sourceDir)) return; + for (const entry of fs3.readdirSync(sourceDir, { withFileTypes: true })) { + const from = path3.join(sourceDir, entry.name); + const to = path3.join(targetDir, entry.name); + if (entry.isDirectory()) { + fs3.mkdirSync(to, { recursive: true }); + mergePublishTrees(from, to); + } else if (!fs3.existsSync(to)) { + fs3.copyFileSync(from, to); + } + } +} +function migrateUserPublishDir(h5Root, user, legacyUsersRoot = null) { + const target = resolvePublishDir(h5Root, user); + fs3.mkdirSync(target, { recursive: true }); + const legacyDirs = [ + resolveLegacyPublishDir(h5Root, user), + legacyUsersRoot ? path3.join(legacyUsersRoot, resolveUsernameSlug(user)) : null + ].filter(Boolean); + for (const legacyDir of legacyDirs) { + if (path3.resolve(legacyDir) === path3.resolve(target)) continue; + mergePublishTrees(legacyDir, target); + } + return target; +} +function buildPublicUrl(publicBaseUrl, publishKey, filename = "") { + const base = `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}`; + if (!filename) return `${base}/`; + const clean = filename.replace(/^\/+/, ""); + return `${base}/${clean.split("/").map(encodeURIComponent).join("/")}`; +} +function buildPublicZonePageUrl(publicBaseUrl, publishKey, filename) { + const clean = String(filename ?? "").replace(/^\/+/, ""); + const relative = clean.startsWith(`${PUBLIC_ZONE_DIR}/`) ? clean : `${PUBLIC_ZONE_DIR}/${clean}`; + return buildPublicUrl(publicBaseUrl, publishKey, relative); +} +function renderPublishSkill({ slug, username, publicBaseUrl, publishDir, displayName }) { + const addressName = resolveUserAddressName({ displayName, username, slug }); + const exampleUrl = buildPublicZonePageUrl(publicBaseUrl, slug, "report.html"); + return `--- +name: ${PUBLISH_SKILL_NAME} +description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u5F00\u8BBF\u95EE\u7684\u9759\u6001 HTML \u62A5\u544A\u4E0E\u9875\u9762\uFF08TKMind H5\uFF09 +--- + +# \u9759\u6001\u9875\u9762 / \u62A5\u544A\u53D1\u5E03\uFF08TKMind\uFF09 + +\u5F53\u7528\u6237\u9700\u8981**\u7F51\u9875\u3001HTML \u62A5\u544A\u3001\u53EF\u89C6\u5316\u9875\u9762\u3001\u53EF\u5206\u4EAB\u94FE\u63A5**\u65F6\uFF0C**\u5FC5\u987B\u5148\u52A0\u8F7D\u672C\u6280\u80FD**\u5E76\u6309\u4EE5\u4E0B\u89C4\u5219\u6267\u884C\u3002 + +## \u786C\u6027\u7EA6\u675F\uFF08\u4E0D\u53EF\u8FDD\u53CD\uFF09 + +1. **\u552F\u4E00\u53EF\u5199\u76EE\u5F55**\uFF1A\`${publishDir}\` +2. **\u7981\u6B62**\u4F7F\u7528\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 \`/Users/...\`\u3001\`../\` \u8DF3\u51FA\u76EE\u5F55\uFF09 +3. **\u5141\u8BB8**\u5728\u672C\u76EE\u5F55\u5185\u4F7F\u7528 \`write_file\`\u3001\`edit_file\`\u3001\`read_file\`\u3001\`list_dir\`\uFF1B**\u7981\u6B62**\u8BBF\u95EE\u6B64\u76EE\u5F55\u5916\u7684\u8DEF\u5F84\uFF08\u542B \`../\`\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\uFF1B\u7CFB\u7EDF\u4F1A\u5728 OS \u5C42\u62E6\u622A\u8D8A\u754C\u8BBF\u95EE\uFF09 +4. **\u7981\u6B62**\u5B50 Agent\u3001\u6269\u5C55\u7BA1\u7406\u3001\u4FEE\u6539\u5DE5\u4F5C\u533A\u5916\u6587\u4EF6 +5. \u9875\u9762\u9ED8\u8BA4\u5199\u5165 \`public/\` \u5206\u533A\uFF0C\u4F7F\u7528\u76F8\u5BF9\u8DEF\u5F84\u5982 \`public/report.html\`\u3001\`public/assets/chart.png\` +6. \u751F\u6210 HTML \u540E\uFF0C\u5411\u7528\u6237\u63D0\u4F9B\u53EF\u8BBF\u95EE\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A + \`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/<\u6587\u4EF6\u540D>\` + \uFF08\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6 URL **\u5FC5\u987B**\u542B \`public/\`\uFF1B\u4EC5\u5F53 HTML \u76F4\u63A5\u5199\u5728 workspace \u6839\u76EE\u5F55\u65F6\u624D\u7701\u7565\uFF09 + +## \u63A8\u8350\u5DE5\u4F5C\u6D41 + +1. \u786E\u8BA4\u9700\u6C42\uFF08\u6807\u9898\u3001\u7AE0\u8282\u3001\u662F\u5426\u8981\u56FE\u8868/\u6837\u5F0F\uFF09 +2. \u4F7F\u7528 \`write_file\` \u521B\u5EFA \`public/\u9875\u9762.html\`\uFF08\u53EF\u542B\u5185\u8054 CSS\uFF1B\u9700\u8981\u65F6\u5728 \`public/assets/\` \u6216\u5DE5\u4F5C\u533A \`assets/\` \u653E\u8D44\u6E90\uFF09 +3. \u5728 \`\` \u5199\u5165 **mindspace-cover** \u5143\u6570\u636E\uFF08\u89C1\u4E0B\u6587\uFF0C\u5FC5\u987B\u4E0E\u9875\u9762\u4E3B\u9898\u4E00\u81F4\uFF09 +4. \u9875\u9762\u5185\u8D44\u6E90\u4F7F\u7528**\u76F8\u5BF9\u8DEF\u5F84**\uFF08\`assets/foo.png\`\uFF09\uFF0C\u4E0D\u8981\u7528\u78C1\u76D8\u7EDD\u5BF9\u8DEF\u5F84 +5. \u4FDD\u5B58 HTML \u540E\uFF0C\u670D\u52A1\u7AEF\u4F1A**\u7ACB\u5373**\u751F\u6210\u540C\u540D\u9884\u89C8\u56FE \`<\u6587\u4EF6\u540D>.thumbnail.svg\`\uFF08Agent \u4EA4\u4E92\u9636\u6BB5\u5373\u751F\u6548\uFF0C\u65E0\u9700\u7B49\u7528\u6237\u4FDD\u5B58\u5230\u300C\u6211\u7684\u7A7A\u95F4\u300D\uFF09 +6. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5 + +## \u56DE\u590D\u683C\u5F0F\uFF08\u5FC5\u987B\uFF09 + +\u5411\u7528\u6237\u4EA4\u4ED8\u9875\u9762\u65F6\uFF0C**\u5FC5\u987B\u4F7F\u7528 Markdown \u53EF\u70B9\u51FB\u94FE\u63A5**\uFF0C\u8BA9\u7528\u6237\u5728\u804A\u5929\u91CC\u76F4\u63A5\u70B9\u5F00\u9884\u89C8\uFF1A + +\`\`\`markdown +[\u666E\u62C9\u63D0 618 \u6D3B\u52A8\u9875](${exampleUrl}) +\`\`\` + +\u8981\u6C42\uFF1A +- **\u5FC5\u987B**\u4F7F\u7528 \`[\u6807\u9898](URL)\` \u683C\u5F0F\uFF0C\u4E0D\u8981\u53EA\u7ED9\u88F8 URL +- \u6807\u9898\u7528\u9875\u9762\u771F\u5B9E\u4E3B\u9898\uFF08\u4E0D\u8981\u7528\u300C\u70B9\u51FB\u8FD9\u91CC\u300D\u300C\u94FE\u63A5\u300D\uFF09 +- \u53EF\u540C\u65F6\u7ED9\u51FA\u672C\u5730\u76F8\u5BF9\u8DEF\u5F84\uFF08\u5982 \`public/pilates-618.html\`\uFF09\u4E0E\u516C\u7F51\u94FE\u63A5 +- \u8BF4\u660E\uFF1A\u9759\u6001\u6587\u4EF6\u4FDD\u5B58\u5373\u751F\u6548\uFF0C**\u65E0\u9700\u91CD\u542F** + +## \u4FE1\u606F\u6D41\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\uFF09 + +\u6BCF\u4E2A HTML \u9875\u9762\u90FD\u5FC5\u987B\u5728 \`\` \u5305\u542B\u4E0E**\u9875\u9762\u4E3B\u9898\u4E00\u81F4**\u7684\u9884\u89C8\u5143\u6570\u636E\u3002\u7CFB\u7EDF\u636E\u6B64\u751F\u6210 **\u7CBE\u7F8E\u7684 3:4 \u4FE1\u606F\u6D41\u5C01\u9762**\uFF08\u300C\u6211\u7684\u7A7A\u95F4\u300D\u5361\u7247 + \u5DE5\u4F5C\u533A \`.thumbnail.svg\` + \u4FDD\u5B58\u5F39\u7A97\u9884\u89C8\uFF09\uFF1A + +\`\`\`html + + +\`\`\` + +| \u5B57\u6BB5 | \u8981\u6C42 | +|------|------| +| \`tag\` | \u4E0E\u9875\u9762\u4E3B\u9898\u4E00\u81F4\uFF1A\u65C5\u884C / \u7F8E\u98DF / \u62A5\u544A / **\u8FD0\u52A8** / **\u6D3B\u52A8** \u7B49\uFF1B\u51B3\u5B9A\u9ED8\u8BA4\u914D\u8272 | +| \`accent\` / \`accent2\` | \u4ECE\u9875\u9762\u4E3B\u8272\u63D0\u53D6\uFF0C\u4E0E hero/\u80CC\u666F\u4E00\u81F4 | +| \`subtitle\` | \u4E00\u53E5\u8BDD\u5356\u70B9\uFF1B\u672A\u5199\u65F6\u7528 description | +| \`cover\` / \`image\` | **\u5FC5\u987B**\u6307\u5411\u9AD8\u8D28\u91CF\u4E3B\u56FE\uFF08\u76F8\u5BF9 HTML \u6216 https\uFF09\uFF1B\u89C1\u4E0B\u6587 | +| \`emoji\` | \u53EF\u9009\uFF1B\u4E5F\u53EF\u5199\u5728 title \u4E2D | + +### \u7CBE\u7F8E\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\u8FBE\u6807\uFF09 + +\u4FDD\u5B58 HTML \u540E\uFF0C\u670D\u52A1\u7AEF\u4F1A**\u7ACB\u5373**\u751F\u6210\u540C\u540D\u9884\u89C8\u56FE \`<\u6587\u4EF6\u540D>.thumbnail.svg\`\uFF08Agent \u4EA4\u4E92\u9636\u6BB5\u5373\u751F\u6548\uFF0C\u65E0\u9700\u7B49\u7528\u6237\u4FDD\u5B58\u5230\u300C\u6211\u7684\u7A7A\u95F4\u300D\uFF09\u3002\u8981\u4EA7\u51FA**\u53EF\u5728\u4FE1\u606F\u6D41\u4E2D\u76F4\u63A5\u5C55\u793A\u7684\u7CBE\u7F8E\u5C01\u9762**\uFF0C\u5FC5\u987B\uFF1A + +1. **\u89C6\u89C9\u7C7B\u9875\u9762**\uFF08\u65C5\u884C\u3001\u7F8E\u98DF\u3001\u6D3B\u52A8\u3001\u8FD0\u52A8\u3001\u54C1\u724C\u3001\u4EA7\u54C1\u3001\u4FC3\u9500\u7B49\uFF09**\u5FC5\u987B**\u5728 \`assets/\` \u653E\u7F6E\u9AD8\u8D28\u91CF hero \u4E3B\u56FE\uFF08\u5EFA\u8BAE\u5BBD\u5EA6 \u22651200px\uFF09\uFF0C\u5E76\u5728 \`cover\` \u5B57\u6BB5\u5F15\u7528 +2. **\u7EAF\u6587\u5B57\u62A5\u544A**\u53EF\u4EC5\u7528\u914D\u8272 + tag\uFF0C\u4F46\u4ECD\u987B\u4FDD\u8BC1 \`accent\` / \`subtitle\` \u4E0E\u9875\u9762\u98CE\u683C\u4E00\u81F4 +3. \`tag\`\u3001\`accent\`\u3001\`accent2\`\u3001\`subtitle\` \u5FC5\u987B\u4E0E\u9875\u9762\u5B9E\u9645\u89C6\u89C9\u4E00\u81F4\uFF1B**\u7981\u6B62**\u7701\u7565 mindspace-cover \u6216\u586B\u65E0\u5173\u9ED8\u8BA4\u503C +4. \u82E5\u7F3A\u5C11 hero \u4E3B\u56FE\uFF0C\u5C01\u9762\u4F1A\u9000\u5316\u4E3A\u7B80\u964B\u9ED8\u8BA4\u56FE\uFF0C**\u89C6\u4E3A\u672A\u8FBE\u6807** + +**\u7981\u6B62**\u4F7F\u7528\u4E0E\u9875\u9762\u65E0\u5173\u7684\u901A\u7528\u98CE\u666F\u56FE\u903B\u8F91\uFF1B\u4FC3\u9500/\u8FD0\u52A8/\u54C1\u724C\u9875\u5FC5\u987B\u5199\u660E \`tag\`\u3001\`accent\` \u548C \`cover\`\u3002 + +## HTML \u6A21\u677F\u5EFA\u8BAE + +- \u5B8C\u6574 \`\`\uFF0C\`lang="zh-CN"\` +- \u79FB\u52A8\u7AEF\u53CB\u597D\uFF1A\`\` +- \u6DF1\u8272/\u6D45\u8272\u4E0E\u5185\u5BB9\u4E00\u81F4\uFF1B\u4E3B\u8272\u5728 CSS \u4E0E mindspace-cover \u4E2D\u4FDD\u6301\u4E00\u81F4 + +## \u67E5\u627E\u6587\u4EF6\uFF08CSV\u3001\u6587\u6863\u7B49\uFF09 + +- \u53EA\u5728**\u5F53\u524D\u5DE5\u4F5C\u533A**\u5185\u4ECE \`.\` \u641C\u7D22\uFF08\u5982 \`list_dir\`\u3001\`list_dir oa\`\uFF0C\u6216 shell \u53EF\u7528\u65F6 \`find . -name '*.csv'\`\uFF09 +- \u7528\u6237\u8BF4\u7684\u300COA \u533A\u300D\u7B49\u89C6\u4E3A\u5DE5\u4F5C\u533A**\u5B50\u76EE\u5F55**\uFF08\u5982 \`oa/\`\uFF09\uFF0C\u7528\u76F8\u5BF9\u8DEF\u5F84\u67E5\u627E +- **\u7981\u6B62**\u641C\u7D22\u4E0A\u7EA7\u76EE\u5F55\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}\` \u6839\u76EE\u5F55\u3001\`/Users\`\u3001\u9879\u76EE\u6839\u76EE\u5F55 +- \u627E\u4E0D\u5230\u65F6\u544A\u77E5\u7528\u6237\u4E0A\u4F20\u6216\u63D0\u4F9B\u76F8\u5BF9\u8DEF\u5F84\uFF0C**\u4E0D\u8981**\u6269\u5927\u5230\u5DE5\u4F5C\u533A\u5916 + +## \u7981\u6B62\u4E8B\u9879 + +- \u4E0D\u8981\u5199\u5165\u5F53\u524D\u5DE5\u4F5C\u533A\u4EE5\u5916\u7684\u4EFB\u4F55\u76EE\u5F55 +- shell \u4EC5\u7528\u4E8E\u672C\u76EE\u5F55\u5185\u6574\u7406\u6587\u4EF6/\u7B80\u5355\u811A\u672C\uFF1B\u4E0D\u8981 \`rm -rf\` \u8D8A\u754C\u8DEF\u5F84\u3001\u4E0D\u8981\u5B89\u88C5\u7CFB\u7EDF\u7EA7\u4F9D\u8D56 + +## \u793A\u4F8B + +\u7528\u6237\uFF1A\u300C\u5199\u4E00\u4EFD\u8D8A\u5357\u65C5\u6E38\u6307\u5357\u7F51\u9875\u300D + +\u6B63\u786E\u505A\u6CD5\uFF1A +- \`write_file\` \u2192 \`public/vietnam-guide.html\`\uFF08\u5185\u5BB9\u5B8C\u6574\uFF09 +- \u56DE\u590D\u94FE\u63A5\uFF1A\`${buildPublicZonePageUrl(publicBaseUrl, slug, "vietnam-guide.html")}\` +`; +} +function renderWorkspaceHints({ slug, username, publishDir, displayName }) { + const addressName = resolveUserAddressName({ displayName, username, slug }); + return `# TKMind \u7528\u6237\u5DE5\u4F5C\u533A\u8FB9\u754C + +\u4F60\u662F\u7528\u6237 **${addressName}** \u7684\u4E13\u5C5E TKMind \u52A9\u624B\u3002\u5F53\u524D\u4F1A\u8BDD**\u552F\u4E00**\u6587\u4EF6\u6839\u76EE\u5F55\uFF1A +\`${publishDir}\` + +${renderBrandingBlock(addressName)} +## \u67E5\u627E / \u8BFB\u53D6\u6587\u4EF6\uFF08CSV\u3001\u6587\u6863\u3001OA \u533A\u7B49\uFF09 + +1. **\u53EA\u80FD**\u5728\u672C\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF0C\u4F8B\u5982 \`list_dir\`\u3001\`list_dir oa\`\uFF0C\u6216 shell \u53EF\u7528\u65F6 \`find . -name '*.csv'\`\u3001\`rg keyword .\` +2. \u7528\u6237\u8BF4\u7684\u300COA \u533A\u300D\u300C\u6587\u6863\u533A\u300D\u7B49\uFF0C**\u5148\u5F53\u4F5C\u5DE5\u4F5C\u533A\u5185\u7684\u5B50\u76EE\u5F55**\uFF08\u5982 \`oa/\`\u3001\`docs/\`\uFF09\uFF0C\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\u67E5\u627E +3. **\u7EDD\u5BF9\u7981\u6B62**\uFF1A + - \u53BB\u4E0A\u7EA7\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}\` \u6839\u76EE\u5F55\u3001\u5176\u5B83\u7528\u6237\u540D\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\u641C\u7D22 + - \u4F7F\u7528 \`/Users\`\u3001\`/home\`\u3001Desktop\u3001Documents\u3001Downloads \u7B49\u4E3B\u673A\u8DEF\u5F84 + - \u5728\u56DE\u590D\u4E2D\u5EFA\u8BAE\u300C\u5728 MindSpace \u76EE\u5F55\u6216\u9644\u8FD1\u76EE\u5F55\u641C\u7D22\u300D\u2014\u2014\u4F60\u6CA1\u6709\u8FD9\u4E2A\u6743\u9650 +4. \u627E\u4E0D\u5230\u6587\u4EF6\u65F6\uFF1A\u8BF4\u660E\u5728\u5176\u5DE5\u4F5C\u533A\u5185\u672A\u627E\u5230\uFF0C\u8BF7\u7528\u6237\u4E0A\u4F20\u6216\u63D0\u4F9B\u76F8\u5BF9\u8DEF\u5F84\uFF1B**\u4E0D\u5F97**\u6269\u5927\u5230\u5DE5\u4F5C\u533A\u5916 + +## \u516C\u7F51\u94FE\u63A5 vs \u672C\u5730\u6587\u4EF6 + +- \u516C\u7F51 URL\uFF08\u5982 \`https://\u2026/MindSpace/<\u7528\u6237>/\`\uFF09**\u4EC5\u4F9B\u7528\u6237\u6D4F\u89C8\u5668\u6253\u5F00\u5DF2\u53D1\u5E03\u7684 HTML \u9875\u9762** +- **\u7981\u6B62**\u7528\u516C\u7F51 URL\u3001curl\u3001wget \u6216 read_image \u53BB\u300C\u5217\u76EE\u5F55\u300D\u300C\u8BFB CSV\u300D\u2014\u2014\u9759\u6001\u7AD9\u70B9\u4E0D\u63D0\u4F9B\u76EE\u5F55\u7D22\u5F15 +- \u6709 \`shell\` \u65F6\u7528 \`ls oa/\`\u3001\`find .\`\u3001\`cat oa/file.csv\`\uFF1B\u6709 \`tree\` \u65F6\u7528 \`tree oa/\` +- \u4E0D\u8981\u8BF4\u300C\u6211\u6CA1\u6709 shell\u300D\u2014\u2014\u82E5\u7CFB\u7EDF\u63D0\u793A\u5DF2\u5217\u51FA shell\uFF0C\u5C31\u5FC5\u987B\u7528 shell \u5728\u5DE5\u4F5C\u533A\u5185\u64CD\u4F5C +`; +} +function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] }) { + const tools = developerTools.length > 0 ? developerTools : ["write_file", "edit_file"]; + const hasSandboxMcp = tools.includes("write_file") || tools.includes("read_file"); + const hasShell = tools.includes("shell"); + const hasListDir = tools.includes("list_dir") || tools.includes("tree"); + const lines = [ + baseConstraints, + "", + "## \u5F53\u524D\u4F1A\u8BDD\u53EF\u7528\u6587\u4EF6\u5DE5\u5177", + `- ${tools.join(", ")}`, + hasSandboxMcp ? "- \u6587\u4EF6\u5DE5\u5177\u7531**\u6C99\u7BB1 MCP** \u63D0\u4F9B\uFF1A\u6240\u6709\u8DEF\u5F84\u5728 OS \u5C42\u5F3A\u5236\u9650\u5236\u5728\u5DE5\u4F5C\u533A\u5185\uFF0C\u8D8A\u754C\u8BBF\u95EE\u4F1A\u7ACB\u5373\u62A5\u9519" : "" + ].filter((l) => l !== ""); + if (hasShell) { + lines.push( + '- **shell \u5DF2\u5F00\u542F**\uFF1A\u53EA\u80FD\u5728\u672C\u5DE5\u4F5C\u533A\u5185\u6267\u884C\uFF08\u5982 `ls oa/`\u3001`find . -name "*.csv"`\u3001`cat oa/\u6587\u4EF6.csv`\uFF09', + "- **\u7981\u6B62** curl/wget \u6216\u8BBF\u95EE\u516C\u7F51 URL \u6765\u8BFB\u53D6\u5DE5\u4F5C\u533A\u6587\u4EF6" + ); + } else { + lines.push( + "- **shell \u672A\u5F00\u542F**\uFF1A\u4E0D\u80FD\u6267\u884C shell\uFF1B\u4E5F\u4E0D\u8981\u7528\u516C\u7F51 URL \u4EE3\u66FF\u672C\u5730\u8BFB\u6587\u4EF6" + ); + } + if (hasListDir) { + lines.push("- **list_dir \u53EF\u7528**\uFF1A`list_dir` \u6216 `list_dir oa` \u67E5\u770B\u76EE\u5F55\u7ED3\u6784"); + } else { + lines.push("- \u9700\u8981\u5217\u76EE\u5F55\u6216\u8BFB CSV \u65F6\uFF0C\u8BF7\u7528\u6237\u4E0A\u4F20\u6587\u4EF6\u5185\u5BB9\u6216\u5F00\u542F list_dir \u80FD\u529B"); + } + lines.push( + "", + "## \u751F\u6210 / \u53D1\u5E03 HTML \u9875\u9762", + "- \u4F60\u6709 write_file/edit_file \u5DE5\u5177\uFF1A**\u5FC5\u987B\u7531\u4F60**\u5199\u5165 `public/xxx.html`\uFF08\u6216\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 `.html`\uFF09", + "- \u5F00\u59CB\u524D\u6267\u884C load_skill \u2192 `static-page-publish`\uFF0C\u6309\u6280\u80FD\u8BF4\u660E\u5199\u5165 mindspace-cover \u5143\u6570\u636E", + "- **\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u5230 public \u6216\u8BF4\u65E0\u6CD5\u751F\u6210\u9875\u9762\uFF08\u9664\u975E write_file \u8C03\u7528\u5931\u8D25\uFF09", + "- \u5B8C\u6210\u540E\u56DE\u590D `[\u9875\u9762\u6807\u9898](\u516C\u7F51URL)` \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B\u5199\u5165 `public/` \u65F6 URL \u5FC5\u987B\u542B `/public/` \u8DEF\u5F84\u6BB5" + ); + return lines.join("\n"); +} +function buildPublishConstraints({ slug, username, publicBaseUrl, publishDir, displayName }) { + const addressName = resolveUserAddressName({ displayName, username, slug }); + return [ + "## TKMind \u7528\u6237\u7A7A\u95F4\u6C99\u7BB1\uFF08\u786C\u6027\u7EA6\u675F\uFF09", + "", + "- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\u7528 **" + addressName + "** \u79F0\u547C\u7528\u6237\uFF0C\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind", + "- \u7981\u6B62\u79F0 goose / Goose / goosed \u6216\u300CRust goose \u9879\u76EE\u300D", + `- \u5F53\u524D\u7528\u6237 **${addressName}** \u7684 Agent \u5DE5\u4F5C\u533A\uFF08\u552F\u4E00\u6587\u4EF6\u6839\u76EE\u5F55\uFF09\uFF1A\`${publishDir}\``, + "- \u7528\u6237\u4E0A\u4F20\u843D\u5728\u5206\u533A\u5B50\u76EE\u5F55\uFF1A`oa/`\u3001`private/`\u3001`public/`\uFF08\u5747\u5728\u4E0A\u8FF0\u5DE5\u4F5C\u533A\u5185\uFF09", + `- \u516C\u7F51 HTML \u524D\u7F00\uFF08\u516C\u5F00\u533A\u9875\u9762\uFF09\uFF1A\`${buildPublicUrl(publicBaseUrl, slug, `${PUBLIC_ZONE_DIR}/`)}\``, + "- **\u67E5\u627E\u6587\u4EF6**\uFF1A\u5728\u5DE5\u4F5C\u533A\u5185\u5BF9\u5E94\u5206\u533A\u641C\u7D22\uFF08\u5982 `oa/2025-12-06T13-34_export.csv`\uFF09\uFF0C\u4E0D\u8981\u641C\u5176\u5B83\u7528\u6237\u76EE\u5F55\u6216\u516C\u7F51 URL", + "- **\u7981\u6B62**\uFF1A\u8BBF\u95EE `assets/` \u5185\u90E8\u8DEF\u5F84\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV", + "- **\u8DEF\u5F84\u89C4\u5219**\uFF1A\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62 `../`\uFF1B\u5DE5\u4F5C\u533A\u5916\u7684\u8DEF\u5F84\u4F1A\u88AB\u7CFB\u7EDF\u62D2\u7EDD\uFF08OS \u5C42\u5F3A\u5236\uFF0C\u975E\u8F6F\u7EA6\u675F\uFF09", + "- **\u751F\u6210\u9875\u9762\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file`/`edit_file` \u5199\u5165 `public/\u9875\u9762.html`", + "- **\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF", + "- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`", + `- \u53D1\u5E03\u6280\u80FD\uFF1A\`${PUBLISH_SKILL_NAME}\`\uFF08\u751F\u6210\u9875\u9762\u524D\u5E94 load_skill\uFF09` + ].join("\n"); +} +function ensureWorkspaceHintsInstalled(publishDir, context) { + const hintsPath = path3.join(publishDir, WORKSPACE_HINTS_FILENAME); + const legacyPath = path3.join(publishDir, LEGACY_WORKSPACE_HINTS_FILENAME); + const content = renderWorkspaceHints(context); + fs3.writeFileSync(hintsPath, content, "utf8"); + if (fs3.existsSync(legacyPath)) { + fs3.unlinkSync(legacyPath); + } + return hintsPath; +} +function ensurePublishSkillInstalled(publishDir, context) { + const skillRoot = path3.join(publishDir, ".agents", "skills", PUBLISH_SKILL_NAME); + fs3.mkdirSync(skillRoot, { recursive: true }); + const skillPath = path3.join(skillRoot, "SKILL.md"); + const content = renderPublishSkill(context); + const existing = fs3.existsSync(skillPath) ? fs3.readFileSync(skillPath, "utf8") : ""; + if (existing !== content) { + fs3.writeFileSync(skillPath, content, "utf8"); + } + return skillPath; +} +function ensureUserPublishLayout({ + h5Root, + publicBaseUrl, + user, + legacyUsersRoot = null, + installWorkspaceHints = false +}) { + migrateUserPublishDir(h5Root, user, legacyUsersRoot); + const slug = resolvePublishKey(user); + const username = user.username ? resolveUsernameSlug(user) : slug; + const displayName = user.displayName ?? user.display_name ?? null; + const publishDir = resolvePublishDir(h5Root, user); + fs3.mkdirSync(path3.join(publishDir, "assets"), { recursive: true }); + const context = { slug, username, displayName, publicBaseUrl, publishDir }; + if (installWorkspaceHints) { + ensureWorkspaceHintsInstalled(publishDir, context); + ensurePublishSkillInstalled(publishDir, context); + } + return { + slug, + username, + displayName, + publishDir, + publicBaseUrl, + publicUrl: buildPublicUrl(publicBaseUrl, slug), + skillName: PUBLISH_SKILL_NAME, + constraints: buildPublishConstraints(context) + }; +} + +// user-memory-profile.mjs +import fs4 from "node:fs"; +import path4 from "node:path"; +var USER_MEMORY_PROFILE_FILENAME = ".tkmind-profile.json"; +var USER_MEMORY_PROFILE_VERSION = 1; +function buildInitialUserMemoryProfile({ + userId, + displayName, + username, + slug, + now = Date.now() +}) { + const addressName = resolveUserAddressName({ displayName, username, slug }); + return { + version: USER_MEMORY_PROFILE_VERSION, + userId, + displayName: addressName, + language: "zh-CN", + responseStyle: "balanced", + preferences: [], + createdAt: now, + updatedAt: now + }; +} +function resolveUserMemoryProfilePath(workspaceRoot) { + return path4.join(workspaceRoot, USER_MEMORY_PROFILE_FILENAME); +} +function loadUserMemoryProfile(workspaceRoot) { + const profilePath = resolveUserMemoryProfilePath(workspaceRoot); + if (!fs4.existsSync(profilePath)) return null; + try { + const raw = fs4.readFileSync(profilePath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + return parsed; + } catch { + return null; + } +} +function ensureUserMemoryProfile(workspaceRoot, context) { + const profilePath = resolveUserMemoryProfilePath(workspaceRoot); + const existing = loadUserMemoryProfile(workspaceRoot); + if (existing) { + const addressName = resolveUserAddressName(context); + const nextDisplayName = addressName || existing.displayName; + if (nextDisplayName && nextDisplayName !== existing.displayName) { + const updated = { + ...existing, + displayName: nextDisplayName, + updatedAt: Date.now() + }; + fs4.writeFileSync(profilePath, `${JSON.stringify(updated, null, 2)} +`, "utf8"); + return updated; + } + return existing; + } + const profile = buildInitialUserMemoryProfile({ + userId: context.userId, + displayName: context.displayName, + username: context.username, + slug: context.slug + }); + fs4.writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)} +`, "utf8"); + return profile; +} +function renderMemoryStoreGuidance({ addressName }) { + const name = addressName || "\u7528\u6237"; + return [ + "## TKMind \u957F\u671F\u8BB0\u5FC6\uFF08L3 / memory \u6269\u5C55\uFF09", + "", + `- \u5F53\u524D\u670D\u52A1\u5BF9\u8C61\uFF1A**${name}**\uFF08\u4EC5\u6B64\u7528\u6237\uFF0C\u4E0D\u5F97\u4E0E\u5176\u4ED6\u7528\u6237\u6DF7\u6DC6\uFF09`, + "- \u4F7F\u7528 memory \u5DE5\u5177\u8BB0\u5F55\u7528\u6237**\u660E\u786E\u8868\u8FBE**\u7684\u504F\u597D\u3001\u4E60\u60EF\u4E0E\u7A33\u5B9A\u4E8B\u5B9E", + "- \u8BB0\u5FC6\u5206\u7C7B\u5EFA\u8BAE\uFF1A", + " - `preference`\uFF1A\u56DE\u590D\u98CE\u683C\u3001\u683C\u5F0F\u3001\u8BED\u8A00\u3001\u79F0\u547C\u4E60\u60EF", + " - `project`\uFF1A\u5F53\u524D\u9879\u76EE\u76EE\u6807\u3001\u7EA6\u5B9A\u3001\u6280\u672F\u9009\u578B\uFF08\u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\u4E92\u8865\uFF09", + " - `fact`\uFF1A\u957F\u671F\u7A33\u5B9A\u3001\u53EF\u590D\u7528\u7684\u4E8B\u5B9E\uFF08\u5982\u90E8\u95E8\u3001\u5E38\u7528\u5DE5\u5177\uFF09", + "- \u7528\u6237\u8BF4\u300C\u5FD8\u8BB0\u2026\u300D\u300C\u4E0D\u8981\u518D\u2026\u300D\u300C\u53D6\u6D88\u504F\u597D\u2026\u300D\u65F6\uFF0C\u5FC5\u987B\u66F4\u65B0\u6216\u5220\u9664\u5BF9\u5E94\u8BB0\u5FC6", + "- **\u7981\u6B62**\u8BB0\u5F55\u5176\u4ED6\u7528\u6237\u7684\u4FE1\u606F\uFF1B**\u7981\u6B62**\u731C\u6D4B\u672A\u660E\u786E\u8868\u8FBE\u7684\u504F\u597D", + "- \u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\uFF08harness\uFF09\u5206\u5DE5\uFF1AL2 \u8BB0\u8FD1\u671F\u5DE5\u4F5C\u4E0E\u51B3\u7B56\u6458\u8981\uFF1BL3 \u8BB0\u53EF\u8DE8\u4F1A\u8BDD\u590D\u7528\u7684\u4E2A\u4EBA\u504F\u597D" + ].join("\n"); +} +function renderPreferenceLines(profile) { + const items = Array.isArray(profile?.preferences) ? profile.preferences : []; + if (items.length === 0) { + return ["- \uFF08\u6682\u65E0\u7ED3\u6784\u5316\u504F\u597D \u2014 \u53EF\u5728\u5BF9\u8BDD\u4E2D\u7528 memory \u5DE5\u5177\u8865\u5145\uFF09"]; + } + return items.map((item) => { + if (typeof item === "string") return `- ${item}`; + const category = item?.category ? `[${item.category}] ` : ""; + const label = item?.label ?? item?.key ?? "preference"; + const value = item?.value ?? item?.content ?? ""; + return `- ${category}${label}\uFF1A${value}`; + }); +} +function renderUserMemoryProfileForHarness(profile, context = {}) { + const addressName = resolveUserAddressName({ + displayName: profile?.displayName ?? context.displayName, + username: context.username, + slug: context.slug + }); + const language = profile?.language === "zh-CN" ? "\u7B80\u4F53\u4E2D\u6587" : profile?.language ?? "\u7B80\u4F53\u4E2D\u6587"; + const styleMap = { + concise: "\u7B80\u6D01\u76F4\u63A5\uFF0C\u5C11\u5E9F\u8BDD", + detailed: "\u8BE6\u7EC6\u5B8C\u6574\uFF0C\u5FC5\u8981\u65F6\u5C55\u5F00", + balanced: "\u7B80\u6D01\u52A1\u5B9E\uFF0C\u5FC5\u8981\u65F6\u8865\u5145\u7EC6\u8282" + }; + const responseStyle = styleMap[profile?.responseStyle] ?? styleMap.balanced; + return [ + "## TKMind \u7528\u6237\u504F\u597D\u753B\u50CF\uFF08L3\uFF09", + "", + `- \u7528\u6237\u79F0\u547C\uFF1A**${addressName}**`, + `- \u754C\u9762\u8BED\u8A00\uFF1A${language}`, + `- \u9ED8\u8BA4\u56DE\u590D\u98CE\u683C\uFF1A${responseStyle}`, + "", + "### \u5DF2\u8BB0\u5F55\u7684\u7ED3\u6784\u5316\u504F\u597D", + ...renderPreferenceLines(profile) + ].join("\n"); +} +function buildSessionMemoryEntries({ + workingDir, + sessionPolicy, + sandboxConstraints = null, + userContext = null +}) { + const entries = []; + if (sandboxConstraints?.trim()) { + entries.push({ + title: "TKMind \u7528\u6237\u7A7A\u95F4\u6C99\u7BB1", + content: sandboxConstraints.trim() + }); + } + const executorGuidance = renderCodeExecutorGuidance(sessionPolicy); + if (executorGuidance) { + entries.push({ + title: "TKMind \u4EE3\u7801\u59D4\u6258\u7B56\u7565", + content: executorGuidance + }); + } + if (!hasMemoryStore(sessionPolicy)) { + return entries; + } + const profile = userContext?.userId ? ensureUserMemoryProfile(workingDir, userContext) : loadUserMemoryProfile(workingDir); + const addressName = resolveUserAddressName({ + displayName: profile?.displayName ?? userContext?.displayName, + username: userContext?.username, + slug: userContext?.slug + }); + entries.push({ + title: "TKMind \u957F\u671F\u8BB0\u5FC6\u89C4\u5219", + content: renderMemoryStoreGuidance({ addressName }) + }); + if (profile) { + entries.push({ + title: "TKMind \u7528\u6237\u504F\u597D\u753B\u50CF", + content: renderUserMemoryProfileForHarness(profile, userContext ?? {}) + }); + } + return entries; +} +function hasMemoryStore(sessionPolicy) { + return (sessionPolicy?.extensionOverrides ?? []).some((ext) => ext.name === "memory"); +} +function availableDelegateExecutors(sessionPolicy) { + const names = new Set((sessionPolicy?.extensionOverrides ?? []).map((ext) => ext.name)); + return ["aider", "openhands"].filter((name) => names.has(name)); +} +function resolveCodeExecutorRouting(sessionPolicy) { + const available = availableDelegateExecutors(sessionPolicy); + const configuredPreferred = String( + sessionPolicy?.policies?.code_delegate_executor ?? "auto" + ).trim(); + const routing = String(sessionPolicy?.policies?.code_task_routing ?? "balanced").trim(); + const preferred = (configuredPreferred === "aider" || configuredPreferred === "openhands") && available.includes(configuredPreferred) ? configuredPreferred : "auto"; + const rules = []; + if (routing === "split") { + rules.push("\u5C0F\u8303\u56F4\u8865\u4E01\u3001\u5C40\u90E8\u4FEE\u590D\u3001\u5C11\u6587\u4EF6\u4FEE\u6539\u4F18\u5148 `aider`\u3002"); + rules.push("\u590D\u6742\u591A\u6587\u4EF6\u6539\u9020\u3001\u4ED3\u5E93\u63A2\u7D22\u3001\u8F83\u91CD\u7684\u547D\u4EE4\u6267\u884C\u4F18\u5148 `openhands`\u3002"); + } else if (routing === "force_aider") { + rules.push("\u53EA\u8981\u4EFB\u52A1\u80FD\u7531 `aider` \u80DC\u4EFB\uFF0C\u5C31\u4F18\u5148\u7EDF\u4E00\u8D70 `aider`\u3002"); + rules.push("\u53EA\u6709 `aider` \u660E\u663E\u65E0\u6CD5\u80DC\u4EFB\u65F6\u624D\u56DE\u9000\u5230 `openhands`\u3002"); + } else if (routing === "force_openhands") { + rules.push("\u53EA\u8981\u4EFB\u52A1\u9700\u8981\u4EE3\u7801\u59D4\u6258\uFF0C\u5C31\u4F18\u5148\u7EDF\u4E00\u8D70 `openhands`\u3002"); + rules.push("\u53EA\u6709\u4EFB\u52A1\u660E\u663E\u66F4\u9002\u5408\u8F7B\u91CF\u8865\u4E01\u65F6\u624D\u56DE\u9000\u5230 `aider`\u3002"); + } else { + rules.push("\u7531 Goose \u6839\u636E\u4EFB\u52A1\u590D\u6742\u5EA6\u3001\u6D89\u53CA\u6587\u4EF6\u6570\u3001\u662F\u5426\u9700\u8981\u4ED3\u5E93\u63A2\u7D22\u548C\u547D\u4EE4\u6267\u884C\uFF0C\u5728 `aider` \u4E0E `openhands` \u4E4B\u95F4\u5E73\u8861\u9009\u62E9\u3002"); + } + if (preferred !== "auto") { + rules.unshift(`\u540E\u53F0\u4F18\u5148\u6267\u884C\u5668\uFF1A**${preferred}**\u3002`); + } else { + rules.unshift("\u540E\u53F0\u4F18\u5148\u6267\u884C\u5668\uFF1A`auto`\uFF0C\u7531 Goose \u7ED3\u5408\u4EFB\u52A1\u7279\u5F81\u51B3\u5B9A\u3002"); + } + return { + available, + preferred, + routing, + rules + }; +} +function containsAny(text, patterns) { + return patterns.some((pattern) => pattern.test(text)); +} +function suggestCodeExecutorForTask(taskText, sessionPolicy) { + const text = String(taskText ?? "").trim().toLowerCase(); + if (!text) return null; + const routing = resolveCodeExecutorRouting(sessionPolicy); + if (routing.available.length === 0) return null; + const codingSignals = [ + /bug|fix|debug|refactor|feature|repo|repository|code|patch|test|compile|build/, + /修复|改代码|重构|功能|仓库|代码|补丁|测试|编译|构建|多文件|命令|脚本/ + ]; + if (!containsAny(text, codingSignals)) { + return null; + } + let aiderScore = 0; + let openhandsScore = 0; + if (containsAny(text, [/small|minor|tiny|simple|one file|single file|quick patch/, /小改|微调|简单修复|单文件|一个文件|快速修复/])) { + aiderScore += 2; + } + if (containsAny(text, [/refactor|multi-?file|repo|repository|end-to-end|investigate|explore/, /重构|多文件|仓库级|全链路|排查|探索代码库/])) { + openhandsScore += 2; + } + if (containsAny(text, [/run|command|terminal|shell|build|compile|test suite/, /执行命令|终端|shell|构建|编译|整套测试/])) { + openhandsScore += 1; + } + if (containsAny(text, [/rename|edit|patch|tweak/, /修改一下|补丁|小范围调整|局部编辑/])) { + aiderScore += 1; + } + if (routing.routing === "split") { + aiderScore += 1; + openhandsScore += 1; + } else if (routing.routing === "force_aider") { + aiderScore += 3; + } else if (routing.routing === "force_openhands") { + openhandsScore += 3; + } + if (routing.preferred === "aider") aiderScore += 2; + if (routing.preferred === "openhands") openhandsScore += 2; + let suggested = null; + if (openhandsScore > aiderScore && routing.available.includes("openhands")) { + suggested = "openhands"; + } else if (aiderScore > openhandsScore && routing.available.includes("aider")) { + suggested = "aider"; + } else if (routing.preferred !== "auto" && routing.available.includes(routing.preferred)) { + suggested = routing.preferred; + } else if (routing.routing === "split" && routing.available.includes("aider") && routing.available.includes("openhands")) { + suggested = openhandsScore >= aiderScore ? "openhands" : "aider"; + } else { + suggested = routing.available[0] ?? null; + } + if (!suggested) return null; + const reason = suggested === "openhands" ? "\u4EFB\u52A1\u770B\u8D77\u6765\u66F4\u50CF\u590D\u6742\u591A\u6587\u4EF6\u6539\u9020\u3001\u4ED3\u5E93\u63A2\u7D22\u6216\u9700\u8981\u66F4\u591A\u547D\u4EE4\u6267\u884C\u3002" : "\u4EFB\u52A1\u770B\u8D77\u6765\u66F4\u50CF\u5C40\u90E8\u8865\u4E01\u3001\u5C0F\u8303\u56F4\u4FEE\u590D\u6216\u8F83\u8F7B\u91CF\u7684\u4EE3\u7801\u4FEE\u6539\u3002"; + return { + suggestedExecutor: suggested, + reason, + routing + }; +} +function buildTaskRoutingAgentText(taskText, sessionPolicy) { + const suggestion = suggestCodeExecutorForTask(taskText, sessionPolicy); + if (!suggestion) return String(taskText ?? "").trim(); + return [ + "\u3010TKMind \u8DEF\u7531\u63D0\u793A\u3011\u4EE5\u4E0B\u63D0\u793A\u4EC5\u7528\u4E8E\u6267\u884C\u5668\u7F16\u6392\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u3002", + `\u5F53\u524D\u4EE3\u7801\u4EFB\u52A1\u5EFA\u8BAE\u4F18\u5148\u59D4\u6258\u7ED9\uFF1A${suggestion.suggestedExecutor}\u3002`, + `\u539F\u56E0\uFF1A${suggestion.reason}`, + "\u82E5\u9996\u9009\u6267\u884C\u5668\u5F53\u524D\u4E0D\u53EF\u7528\u6216\u660E\u663E\u4E0D\u9002\u5408\uFF0C\u53EF\u56DE\u9000\u5230\u53E6\u4E00\u4E2A\u5DF2\u6388\u6743\u6267\u884C\u5668\uFF0C\u5E76\u5728\u6700\u7EC8\u56DE\u590D\u91CC\u7B80\u8FF0\u539F\u56E0\u3002", + "", + String(taskText ?? "").trim() + ].join("\n"); +} +function renderCodeExecutorGuidance(sessionPolicy) { + const { available, preferred, routing, rules } = resolveCodeExecutorRouting(sessionPolicy); + if (available.length === 0) return ""; + const availableText = available.join(" / "); + const lines = [ + "## TKMind \u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668\u8DEF\u7531", + "", + `- \u5F53\u524D\u53EF\u7528\u7684\u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668\uFF1A${availableText}` + ]; + if (preferred === "aider" || preferred === "openhands") { + lines.push(`- \u540E\u53F0\u7B56\u7565\u8981\u6C42\uFF1A\u591A\u6587\u4EF6\u7F16\u7801\u4EFB\u52A1\u4F18\u5148\u4F7F\u7528 **${preferred}**\u3002`); + lines.push( + "- \u5982\u679C\u9996\u9009\u6267\u884C\u5668\u5F53\u524D\u4E0D\u53EF\u7528\u3001\u65E0\u6CD5\u5B8C\u6210\u4EFB\u52A1\u3001\u6216\u4EFB\u52A1\u660E\u663E\u66F4\u9002\u5408\u53E6\u4E00\u6267\u884C\u5668\uFF0C\u53EF\u56DE\u9000\u5230\u53E6\u4E00\u4E2A\u5DF2\u6388\u6743\u6267\u884C\u5668\uFF0C\u5E76\u5728\u56DE\u590D\u4E2D\u8BF4\u660E\u539F\u56E0\u3002" + ); + } else { + lines.push("- \u540E\u53F0\u7B56\u7565\u8981\u6C42\uFF1A\u7531 Goose \u6839\u636E\u4EFB\u52A1\u590D\u6742\u5EA6\u5728\u5DF2\u6388\u6743\u6267\u884C\u5668\u4E2D\u81EA\u52A8\u9009\u62E9\u3002"); + } + lines.push(`- \u4EFB\u52A1\u8DEF\u7531\u6A21\u5F0F\uFF1A\`${routing}\``); + for (const rule of rules) { + lines.push(`- ${rule}`); + } + lines.push("- \u82E5\u5F53\u524D\u4EFB\u52A1\u4E0D\u9700\u8981\u59D4\u6258\u7F16\u7801\u6267\u884C\u5668\uFF0C\u53EF\u7EE7\u7EED\u76F4\u63A5\u4F7F\u7528 Goose \u81EA\u8EAB\u5DE5\u5177\u5B8C\u6210\u3002"); + return lines.join("\n"); +} + +// session-reconcile.mjs +import path5 from "node:path"; +function extensionName(config) { + return config?.name ?? null; +} +function allowedExtensionNames(extensionOverrides) { + if (!extensionOverrides) return null; + return new Set(extensionOverrides.map((item) => item.name).filter(Boolean)); +} +function extensionToolsKey(config) { + const tools = config?.available_tools ?? config?.availableTools ?? []; + return [...tools].sort().join("\0"); +} +function extensionConfigsMatch(sessionExt, desiredConfig) { + if (extensionName(sessionExt) !== extensionName(desiredConfig)) return false; + return extensionToolsKey(sessionExt) === extensionToolsKey(desiredConfig); +} +function extensionsNeedingRefresh(currentExtensions, desiredExtensions) { + const current = currentExtensions ?? []; + const desired = desiredExtensions ?? []; + const toRemove = []; + const toAdd = []; + for (const ext of current) { + const name = extensionName(ext); + if (!name) continue; + const wanted = desired.find((item) => extensionName(item) === name); + if (!wanted) { + toRemove.push(name); + continue; + } + if (!extensionConfigsMatch(ext, wanted)) { + toRemove.push(name); + toAdd.push(wanted); + } + } + for (const config of desired) { + const name = extensionName(config); + if (!name) continue; + const exists = current.some((ext) => extensionName(ext) === name); + if (!exists) { + toAdd.push(config); + } + } + return { toRemove, toAdd }; +} +function samePath(left, right) { + if (!left || !right) return false; + return path5.resolve(left) === path5.resolve(right); +} +async function readJson(upstream) { + const text = await upstream.text(); + if (!upstream.ok) { + throw new Error(text || `upstream ${upstream.status}`); + } + return text ? JSON.parse(text) : null; +} +function isInvalidDirectoryPathError(err) { + return err instanceof Error && /Invalid directory path/i.test(err.message); +} +async function reconcileAgentSession(apiFetch2, sessionId, { + workingDir, + sessionPolicy, + sandboxConstraints = null, + userContext = null, + tolerateInvalidWorkingDir = false +}) { + if (sessionPolicy?.unrestricted) return; + const session = await readJson(await apiFetch2(`/sessions/${sessionId}`)); + let needsRestart = false; + if (workingDir && !samePath(session?.working_dir, workingDir)) { + try { + await readJson( + await apiFetch2("/agent/update_working_dir", { + method: "POST", + body: JSON.stringify({ session_id: sessionId, working_dir: workingDir }) + }) + ); + } catch (err) { + if (!(tolerateInvalidWorkingDir && isInvalidDirectoryPathError(err))) { + throw err; + } + console.warn( + `[session-reconcile] skip invalid working_dir during resume for session ${sessionId}: ${err.message}` + ); + } + } + if (sessionPolicy?.gooseMode && session?.goose_mode !== sessionPolicy.gooseMode) { + await readJson( + await apiFetch2("/agent/update_session", { + method: "POST", + body: JSON.stringify({ + session_id: sessionId, + goose_mode: sessionPolicy.gooseMode + }) + }) + ); + needsRestart = true; + } + const desired = sessionPolicy?.extensionOverrides ?? []; + const allowed = allowedExtensionNames(desired); + const currentPayload = await readJson(await apiFetch2(`/sessions/${sessionId}/extensions`)); + const current = currentPayload?.extensions ?? []; + let removedAny = false; + for (const ext of current) { + const name = extensionName(ext); + if (!name || allowed.has(name)) continue; + await readJson( + await apiFetch2("/agent/remove_extension", { + method: "POST", + body: JSON.stringify({ session_id: sessionId, name }) + }) + ); + removedAny = true; + } + const refreshed = removedAny ? (await readJson(await apiFetch2(`/sessions/${sessionId}/extensions`)))?.extensions ?? [] : current; + const { toRemove, toAdd } = extensionsNeedingRefresh(refreshed, desired); + for (const name of toRemove) { + await readJson( + await apiFetch2("/agent/remove_extension", { + method: "POST", + body: JSON.stringify({ session_id: sessionId, name }) + }) + ); + needsRestart = true; + } + for (const config of toAdd) { + await readJson( + await apiFetch2("/agent/add_extension", { + method: "POST", + body: JSON.stringify({ session_id: sessionId, config }) + }) + ); + needsRestart = true; + } + if (needsRestart) { + await readJson( + await apiFetch2("/agent/restart", { + method: "POST", + body: JSON.stringify({ session_id: sessionId }) + }) + ); + } + const sandboxText = sandboxConstraints?.trim() ? buildSandboxSessionConstraints({ + baseConstraints: sandboxConstraints, + developerTools: developerToolsFromPolicy(sessionPolicy) + }) : null; + const memoryEntries = buildSessionMemoryEntries({ + workingDir, + sessionPolicy, + sandboxConstraints: sandboxText, + userContext + }); + if (memoryEntries.length > 0) { + for (const entry of memoryEntries) { + if (!entry.content?.trim()) continue; + await apiFetch2("/agent/harness_remember", { + method: "POST", + body: JSON.stringify({ + sessionId, + content: entry.content, + title: entry.title + }) + }); + } + await apiFetch2("/agent/harness_bootstrap", { + method: "POST", + body: JSON.stringify({ + sessionId, + force: true + }) + }); + } +} + +// tkmind-proxy.mjs +var insecureDispatcher = new Agent({ + connect: { rejectUnauthorized: false } +}); +function isHttpsTarget(target) { + return target.startsWith("https://"); +} +function sanitizeUserFacingProxyMessage(message, fallback = "\u540E\u7AEF\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") { + const normalized = String(message ?? "").trim(); + if (!normalized) return fallback; + if (!/goose|goosed/i.test(normalized)) return normalized; + if (/超时|timeout/i.test(normalized)) { + return "\u540E\u7AEF\u8FDE\u63A5\u8D85\u65F6\uFF0C\u8BF7\u786E\u8BA4\u540E\u7AEF\u670D\u52A1\u6B63\u5E38\u540E\u91CD\u8BD5"; + } + if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) { + return fallback; + } + return normalized.replace(/\bgoosed\b/gi, "\u540E\u7AEF\u670D\u52A1").replace(/\bgoose\b/gi, "\u540E\u7AEF"); +} +async function apiFetch(target, apiSecret, pathname, init = {}) { + const url = new URL(pathname, target); + const headers = { + ...init.headers ?? {}, + "X-Secret-Key": apiSecret + }; + if (init.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + return undiciFetch(url, { + ...init, + headers, + dispatcher: isHttpsTarget(target) ? insecureDispatcher : void 0 + }); +} +async function readJsonBody(req) { + if (req.method === "GET" || req.method === "HEAD") return null; + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + if (chunks.length === 0) return null; + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) return null; + return JSON.parse(raw); +} +function sendProxyResponse(res, upstream) { + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + if (key === "transfer-encoding") return; + res.setHeader(key, value); + }); + if (!upstream.body) { + res.end(); + return; + } + Readable.fromWeb(upstream.body).pipe(res); +} +function extractSessionId(req, body) { + const fromParams = req.params?.sessionId ?? req.params?.id; + if (fromParams) return fromParams; + if (body?.session_id) return body.session_id; + if (body?.sessionId) return body.sessionId; + return null; +} +function firstUserText(message) { + return message?.content?.find?.((item) => item?.type === "text" && typeof item.text === "string")?.text ?? ""; +} +var IMAGE_URL_LINE_RE = /^\[图片\d+]:\s*(\S.+)$/; +function extractImageUrlsFromMessage(userMessage) { + const urls = []; + const imageUrls = userMessage?.metadata?.imageUrls; + if (Array.isArray(imageUrls)) { + urls.push(...imageUrls); + } + const content = userMessage?.content; + if (Array.isArray(content)) { + for (const item of content) { + if (item?.type === "image_url" && item.image_url?.url) { + urls.push(item.image_url.url); + continue; + } + if (item?.type !== "text" || typeof item.text !== "string") continue; + for (const line of item.text.split("\n")) { + const match = line.trim().match(IMAGE_URL_LINE_RE); + if (match?.[1]) urls.push(match[1].trim()); + } + } + } + return [...new Set(urls.filter((url) => typeof url === "string" && url.trim()))]; +} +function messageHasImages(userMessage) { + return extractImageUrlsFromMessage(userMessage).length > 0; +} +function injectTaskRoutingHint(body, sessionPolicy) { + const originalText = firstUserText(body?.user_message); + if (!originalText?.trim()) return body; + const agentText = buildTaskRoutingAgentText(originalText, sessionPolicy); + if (!agentText || agentText === originalText) return body; + const userMessage = body.user_message ?? {}; + const content = Array.isArray(userMessage.content) ? [...userMessage.content] : []; + const firstTextIndex = content.findIndex( + (item) => item?.type === "text" && typeof item.text === "string" + ); + if (firstTextIndex >= 0) { + content[firstTextIndex] = { ...content[firstTextIndex], text: agentText }; + } else { + content.unshift({ type: "text", text: agentText }); + } + return { + ...body, + user_message: { + ...userMessage, + content, + metadata: { + ...userMessage.metadata ?? {}, + displayText: userMessage.metadata?.displayText && String(userMessage.metadata.displayText).trim() ? userMessage.metadata.displayText : originalText + } + } + }; +} +async function buildVisionPayload({ + userMessage, + userId, + publishLayout, + localFetchAsset, + llmProviderService: llmProviderService2 +}) { + if (!localFetchAsset || !llmProviderService2 || !userId) return null; + const rawImageUrls = extractImageUrlsFromMessage(userMessage); + if (rawImageUrls.length === 0) return null; + const imageItems = []; + for (const rawUrl of rawImageUrls) { + try { + const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)\/download/); + if (!match) continue; + const assetId = decodeURIComponent(match[1]); + const { buffer, mimeType } = await localFetchAsset(userId, assetId); + let relativePath = rawUrl; + try { + const parsed = new URL(rawUrl); + relativePath = parsed.pathname + parsed.search; + } catch { + } + imageItems.push({ mimeType, data: buffer.toString("base64"), relativePath, rawUrl }); + } catch (err) { + console.warn("Vision image fetch skipped:", err instanceof Error ? err.message : err); + } + } + if (imageItems.length === 0) return null; + const visionDescription = await llmProviderService2.analyzeImagesWithVision(imageItems, "\u8BF7\u8BE6\u7EC6\u63CF\u8FF0\u56FE\u7247\u7684\u89C6\u89C9\u5185\u5BB9\uFF1A\u4E3B\u4F53\u3001\u989C\u8272\u3001\u98CE\u683C\u3001\u6784\u56FE\uFF0C\u4E0D\u8981\u751F\u6210\u4EE3\u7801\u6216\u9875\u9762\u65B9\u6848").catch(() => null); + const pathList = imageItems.map((item, i) => `\u56FE\u7247${i + 1}: \u56FE\u7247${i + 1}`).join(" "); + const publicUrlPrefix = publishLayout?.publicUrl ? `${String(publishLayout.publicUrl).replace(/\/$/, "")}/public/` : null; + const injectedNote = "\n\n\u3010TKMind \u56FE\u7247\u5206\u6790\u7ED3\u679C \u2014 \u4EC5\u4F9B\u6267\u884C\u53C2\u8003\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u6B64\u6BB5\u5185\u5BB9\u3011\n" + (visionDescription ? `Qwen VL \u56FE\u7247\u63CF\u8FF0\uFF1A +${visionDescription} + +` : "") + `\u56FE\u7247 HTML \u5D4C\u5165\u8DEF\u5F84\uFF08\u76F4\u63A5\u5199\u5165 \u6807\u7B7E\uFF0C\u6D4F\u89C8\u5668\u6709 cookie \u53EF\u76F4\u63A5\u52A0\u8F7D\uFF0C\u65E0\u9700 fetch\uFF09\uFF1A +${pathList} +\u6267\u884C\u8981\u6C42\uFF1A\u5FC5\u987B\u5148\u8C03\u7528 load_skill \u2192 static-page-publish\uFF08\u6BCF\u6B21\u751F\u6210\u9875\u9762\u90FD\u8981\u8C03\u7528\uFF0C\u4E0D\u53EF\u7701\u7565\uFF09\uFF0C\u518D\u7528 write_file \u5199\u5165 public/\u9875\u9762.html\uFF1B` + (publicUrlPrefix ? `\u5B8C\u6210\u540E\u5411\u7528\u6237\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A[\u9875\u9762\u6807\u9898](${publicUrlPrefix}<\u6587\u4EF6\u540D>.html)\uFF1B` : "\u5B8C\u6210\u540E\u6309\u6280\u80FD\u8BF4\u660E\u91CC\u7684\u94FE\u63A5\u683C\u5F0F\u7ED9\u7528\u6237\u4E00\u4E2A Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B") + "\u4E0D\u8981\u5411\u7528\u6237\u5C55\u793A HTML \u4EE3\u7801\u5757\u3002"; + let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : []; + for (const item of imageItems) { + updatedContent = updatedContent.map((c) => { + if (c?.type !== "text" || typeof c.text !== "string") return c; + const updated = c.text.replaceAll(item.rawUrl, item.relativePath); + return updated === c.text ? c : { ...c, text: updated }; + }); + } + const lastTextIdx = updatedContent.reduceRight( + (found, item, i) => found === -1 && item?.type === "text" ? i : found, + -1 + ); + if (lastTextIdx >= 0) { + updatedContent[lastTextIdx] = { + ...updatedContent[lastTextIdx], + text: updatedContent[lastTextIdx].text + injectedNote + }; + } else { + updatedContent.push({ type: "text", text: injectedNote }); + } + return { + userMessage: { ...userMessage, content: updatedContent }, + billableImageCount: visionDescription ? 1 : 0 + }; +} +function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAuth2, llmProviderService: llmProviderService2, localFetchAsset, subscriptionService: subscriptionService2 }) { + const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : []; + const primaryTarget = targets[0] ?? apiTarget ?? ""; + let rrIdx = 0; + async function targetHealthy(target) { + try { + const upstream = await apiFetch(target, apiSecret, "/status", { + method: "GET", + signal: AbortSignal.timeout(1500) + }); + return upstream.ok; + } catch { + return false; + } + } + async function pickTarget() { + if (targets.length <= 1) return primaryTarget; + for (let i = 0; i < targets.length; i += 1) { + const target = targets[rrIdx]; + rrIdx = (rrIdx + 1) % targets.length; + if (await targetHealthy(target)) return target; + } + return primaryTarget; + } + async function resolveTarget(sessionId) { + if (targets.length <= 1 || !sessionId) return primaryTarget; + try { + const node = await userAuth2.getSessionNode(sessionId); + return targets[node] ?? primaryTarget; + } catch { + return primaryTarget; + } + } + async function applySessionLlmProvider(sessionId) { + if (!llmProviderService2 || !sessionId) return null; + try { + const target = await resolveTarget(sessionId); + return await llmProviderService2.applyBestProviderForSession( + sessionId, + (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init) + ); + } catch (err) { + console.warn( + "LLM provider apply skipped:", + err instanceof Error ? err.message : err + ); + return null; + } + } + async function applyLocalFallbackForSession(sessionId) { + if (!llmProviderService2 || !sessionId) return null; + const target = await resolveTarget(sessionId); + return llmProviderService2.applyLocalFallbackForSession( + sessionId, + (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init) + ); + } + const visionActiveSessions = /* @__PURE__ */ new Set(); + async function applyVisionProviderForSession(sessionId) { + if (!llmProviderService2 || !sessionId) return null; + const target = await resolveTarget(sessionId); + return llmProviderService2.applyVisionProviderForSession( + sessionId, + (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init) + ); + } + async function buildVisionBody(userMessage, userId, publishLayout) { + return buildVisionPayload({ + userMessage, + userId, + publishLayout, + localFetchAsset, + llmProviderService: llmProviderService2 + }); + } + async function reconcileSessionPolicyForUser(userId, sessionId) { + if (!userId || !sessionId) return; + const target = await resolveTarget(sessionId); + const workingDir = await userAuth2.resolveWorkingDir(userId); + const sessionPolicy = await userAuth2.getAgentSessionPolicy(userId); + const publishLayout = await userAuth2.getUserPublishLayout(userId); + await reconcileAgentSession( + (pathname, init) => apiFetch(target, apiSecret, pathname, init), + sessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + tolerateInvalidWorkingDir: true, + userContext: publishLayout ? { + userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + } + ); + } + const requireUser = async (req, res, next) => { + try { + const session = req.userSession; + if (!session) { + res.status(401).json({ message: "\u672A\u767B\u5F55" }); + return; + } + const me = await userAuth2.getMe(req.userToken); + if (!me) { + res.status(401).json({ message: "\u767B\u5F55\u5DF2\u8FC7\u671F" }); + return; + } + req.currentUser = me; + next(); + } catch (err) { + res.status(500).json({ message: err instanceof Error ? err.message : "\u8BA4\u8BC1\u5931\u8D25" }); + } + }; + const ensureChatAllowed = async (req, res, next) => { + const gate = await userAuth2.canUseChat(req.currentUser.id); + if (!gate.ok) { + res.status(402).json({ + message: gate.message, + code: gate.code, + balanceCents: gate.balanceCents, + minRechargeCents: gate.minRechargeCents, + suggestedTiers: gate.suggestedTiers + }); + return; + } + next(); + }; + const handlers = { + "POST /agent/start": [ + requireUser, + ensureChatAllowed, + async (req, res) => { + try { + const workingDir = await userAuth2.resolveWorkingDir(req.currentUser.id); + const sessionPolicy = await userAuth2.getAgentSessionPolicy(req.currentUser.id); + const startTarget = await pickTarget(); + const upstream = await apiFetch(startTarget, apiSecret, "/agent/start", { + method: "POST", + body: JSON.stringify({ + working_dir: workingDir, + enable_context_memory: sessionPolicy.enableContextMemory, + ...sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {}, + ...req.body?.recipe ? { recipe: req.body.recipe } : {} + }) + }); + const text = await upstream.text(); + if (!upstream.ok) { + res.status(upstream.status).send(text); + return; + } + const session = JSON.parse(text); + if (session?.id) { + await userAuth2.registerAgentSession( + req.currentUser.id, + session.id, + Math.max(0, targets.indexOf(startTarget)) + ); + if (sessionPolicy.gooseMode) { + const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", { + method: "POST", + body: JSON.stringify({ + session_id: session.id, + goose_mode: sessionPolicy.gooseMode + }) + }); + if (!modeRes.ok) { + const modeText = await modeRes.text().catch(() => ""); + res.status(modeRes.status).send(modeText || "\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25"); + return; + } + } + const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id); + const api2 = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init); + try { + await reconcileAgentSession(api2, session.id, { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout ? { + userId: req.currentUser.id, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + }); + } catch (reconcileErr) { + res.status(500).json({ + message: reconcileErr instanceof Error ? `\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${reconcileErr.message}` : "\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25" + }); + return; + } + await applySessionLlmProvider(session.id); + } + res.status(upstream.status).json(session); + } catch (err) { + res.status(500).json({ message: err instanceof Error ? err.message : "\u542F\u52A8\u4F1A\u8BDD\u5931\u8D25" }); + } + } + ], + "POST /agent/resume": [ + requireUser, + ensureChatAllowed, + async (req, res) => { + try { + const sessionId = req.body?.session_id; + if (!sessionId) { + res.status(400).json({ message: "\u7F3A\u5C11 session_id" }); + return; + } + const owns = await userAuth2.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + return; + } + const resumeTarget = await resolveTarget(sessionId); + const upstream = await apiFetch(resumeTarget, apiSecret, "/agent/resume", { + method: "POST", + body: JSON.stringify(req.body ?? {}) + }); + const text = await upstream.text(); + if (!upstream.ok) { + res.status(upstream.status).send(text); + return; + } + const payload = JSON.parse(text); + const skipReconcile = req.body?.skip_reconcile === true; + if (!skipReconcile) { + const workingDir = await userAuth2.resolveWorkingDir(req.currentUser.id); + const sessionPolicy = await userAuth2.getAgentSessionPolicy(req.currentUser.id); + const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id); + try { + await reconcileAgentSession( + (pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init), + sessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + tolerateInvalidWorkingDir: true, + userContext: publishLayout ? { + userId: req.currentUser.id, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + } + ); + } catch (reconcileErr) { + res.status(500).json({ + message: reconcileErr instanceof Error ? `\u4F1A\u8BDD\u6062\u590D\u540E\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${reconcileErr.message}` : "\u4F1A\u8BDD\u6062\u590D\u540E\u7B56\u7565\u540C\u6B65\u5931\u8D25" + }); + return; + } + } + await applySessionLlmProvider(sessionId); + res.status(upstream.status).json(payload); + } catch (err) { + res.status(500).json({ + message: sanitizeUserFacingProxyMessage( + err instanceof Error ? err.message : "\u6062\u590D\u4F1A\u8BDD\u5931\u8D25", + "\u6062\u590D\u4F1A\u8BDD\u5931\u8D25" + ) + }); + } + } + ], + "GET /sessions": [ + requireUser, + async (req, res) => { + try { + const owned = await userAuth2.listOwnedSessionIds(req.currentUser.id); + const sessionsById = /* @__PURE__ */ new Map(); + let healthyTargets = 0; + let lastFailure = null; + for (const target of targets) { + try { + const upstream = await apiFetch(target, apiSecret, "/sessions", { + method: "GET", + signal: AbortSignal.timeout(3e3) + }); + const text = await upstream.text(); + if (!upstream.ok) { + lastFailure = text || `upstream ${upstream.status}`; + continue; + } + healthyTargets += 1; + const payload = JSON.parse(text); + for (const item of payload.sessions ?? []) { + if (owned.has(item.id)) sessionsById.set(item.id, item); + } + } catch (err) { + lastFailure = sanitizeUserFacingProxyMessage( + err instanceof Error ? err.message : "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25", + "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25" + ); + } + } + if (healthyTargets === 0) { + res.status(502).json({ message: lastFailure ?? "\u540E\u7AEF\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" }); + return; + } + if (healthyTargets < targets.length) { + res.setHeader("X-TKMind-Degraded", "1"); + } + res.json({ sessions: [...sessionsById.values()] }); + } catch (err) { + res.status(500).json({ + message: sanitizeUserFacingProxyMessage( + err instanceof Error ? err.message : "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25", + "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25" + ) + }); + } + } + ] + }; + const sessionScoped = (build) => [ + requireUser, + async (req, res, next) => { + try { + const body = req.body ?? await readJsonBody(req); + req.body = body; + const sessionId = extractSessionId(req, body); + if (!sessionId) { + res.status(400).json({ message: "\u7F3A\u5C11 session_id" }); + return; + } + const owns = await userAuth2.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + return; + } + req.agentSessionId = sessionId; + await build(req, res, next); + } catch (err) { + res.status(500).json({ + message: sanitizeUserFacingProxyMessage( + err instanceof Error ? err.message : "\u8BF7\u6C42\u5931\u8D25", + "\u8BF7\u6C42\u5931\u8D25" + ) + }); + } + } + ]; + const proxySessionEvents = async (req, res, sessionId, { onAfterFinish } = {}) => { + try { + const pathname = `/sessions/${sessionId}/events`; + const sessionTarget = await resolveTarget(sessionId); + const upstream = await apiFetch(sessionTarget, apiSecret, pathname, { + method: "GET", + headers: { + Accept: "text/event-stream", + "Last-Event-ID": req.get("last-event-id") ?? "" + } + }); + if (!upstream.ok || !upstream.body) { + const text = await upstream.text().catch(() => ""); + res.status(upstream.status).send(text); + return; + } + res.status(upstream.status); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + let pendingBalance = null; + const billingTransform = createSseBillingTransform({ + onFinish: async (event) => { + const result = await userAuth2.billSessionUsage( + req.currentUser.id, + sessionId, + event.token_state, + null + ); + if (result.ok && result.costCents > 0 && result.balanceCents != null) { + pendingBalance = { + balanceCents: result.balanceCents, + tokensUsed: result.tokensUsed ?? void 0, + lastUsage: { + inputTokens: result.deltaInputTokens ?? 0, + outputTokens: result.deltaOutputTokens ?? 0, + costCents: result.costCents + } + }; + } + if (typeof onAfterFinish === "function") { + void Promise.resolve().then(() => onAfterFinish(sessionId, req.currentUser.id)).catch(() => { + }); + } + } + }); + const source = Readable.fromWeb(upstream.body); + billingTransform.on("data", (chunk) => { + res.write(chunk); + if (pendingBalance != null) { + res.write(appendBalanceEvent(pendingBalance)); + pendingBalance = null; + } + }); + billingTransform.on("end", () => res.end()); + billingTransform.on("error", () => res.end()); + source.on("error", () => res.end()); + source.pipe(billingTransform); + } catch (err) { + res.status(502).json({ + message: sanitizeUserFacingProxyMessage( + err instanceof Error ? err.message : "SSE \u4EE3\u7406\u5931\u8D25", + "SSE \u4EE3\u7406\u5931\u8D25" + ) + }); + } + }; + const proxyFallback = async (req, res) => { + try { + const pathname = req.originalUrl.replace(/^\/api/, "") || "/"; + if (isNativeH5ApiPath(pathname)) { + res.status(404).json({ + message: "H5 \u672C\u5730\u63A5\u53E3\u672A\u627E\u5230\uFF0C\u8BF7\u786E\u8BA4\u670D\u52A1\u7AEF\u5DF2\u66F4\u65B0\u5E76\u91CD\u542F", + code: "not_found" + }); + return; + } + const policyState = await userAuth2.resolveUserPolicies(req.currentUser); + const capabilityState = await userAuth2.resolveUserCapabilities(req.currentUser); + const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, { + unrestricted: policyState.unrestricted + }); + if (!gate.allowed) { + res.status(403).json({ message: gate.reason ?? "\u8BE5 API \u5DF2\u88AB\u7B56\u7565\u7981\u6B62" }); + return; + } + if (/^\/agent\/harness_(bootstrap|remember)$/.test(pathname) && !capabilityState.unrestricted && !capabilityState.capabilities.context_memory) { + res.status(403).json({ message: "\u5F53\u524D\u8D26\u6237\u672A\u5F00\u901A\u9879\u76EE\u8BB0\u5FC6\uFF0C\u65E0\u6CD5\u8BBF\u95EE\u8BE5 API" }); + return; + } + const isReplyPath = pathname.match(/^\/sessions\/[^/]+\/reply$/) && req.method === "POST"; + const sessionMatch = pathname.match(/^\/sessions\/([^/]+)/); + let baseBody = req.body; + if (isReplyPath && !policyState.unrestricted) { + baseBody = injectTaskRoutingHint( + req.body, + await userAuth2.getAgentSessionPolicy(req.currentUser.id) + ); + } + if (isReplyPath && llmProviderService2) { + const sessionId = sessionMatch?.[1]; + if (sessionId) { + const hasImages = messageHasImages(req.body?.user_message); + if (hasImages && await llmProviderService2.hasVisionKey()) { + const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser?.id).catch(() => null); + const visionResult = await buildVisionBody( + baseBody?.user_message ?? req.body?.user_message, + req.currentUser?.id, + publishLayout + ).catch(() => null); + if (visionResult?.userMessage) { + baseBody = { ...baseBody ?? req.body, user_message: visionResult.userMessage }; + } + if (visionResult?.billableImageCount > 0 && subscriptionService2) { + await subscriptionService2.consumeImageQuota( + req.currentUser.id, + visionResult.billableImageCount + ).catch((err) => { + console.warn( + "Subscription image quota consume skipped:", + err instanceof Error ? err.message : err + ); + }); + } + } + } + } + const body = req.method === "GET" || req.method === "HEAD" ? void 0 : baseBody ? JSON.stringify(baseBody) : void 0; + const fallbackTarget = req.goosedTarget ?? (sessionMatch ? await resolveTarget(sessionMatch[1]) : primaryTarget); + const upstream = await apiFetch(fallbackTarget, apiSecret, pathname, { + method: req.method, + body, + headers: { + Accept: req.get("accept") ?? "*/*", + "Last-Event-ID": req.get("last-event-id") ?? "" + } + }); + sendProxyResponse(res, upstream); + } catch (err) { + res.status(502).json({ + message: sanitizeUserFacingProxyMessage( + err instanceof Error ? err.message : "\u4EE3\u7406\u5931\u8D25", + "\u4EE3\u7406\u5931\u8D25" + ) + }); + } + }; + return { + requireUser, + ensureChatAllowed, + applySessionLlmProvider, + applyLocalFallbackForSession, + applyVisionProviderForSession, + reconcileSessionPolicyForUser, + handlers, + sessionScoped, + proxyFallback, + proxySessionEvents, + resolveTarget, + apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init), + apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init) + }; +} + +// user-auth.mjs +import crypto4 from "node:crypto"; +import fs7 from "node:fs"; +import net from "node:net"; +import path8 from "node:path"; +import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from "@node-rs/argon2"; + +// billing.mjs +function loadBillingConfig() { + const useBackendCost = process.env.H5_USE_BACKEND_COST === "1"; + const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1); + return { + useBackendCost, + usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2), + marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1, + inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2), + outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6), + minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1) + }; +} +function normalizeTokenState(raw) { + if (!raw || typeof raw !== "object") { + return { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + accumulatedCost: null + }; + } + return { + inputTokens: Number(raw.inputTokens ?? raw.input_tokens ?? 0), + outputTokens: Number(raw.outputTokens ?? raw.output_tokens ?? 0), + totalTokens: Number(raw.totalTokens ?? raw.total_tokens ?? 0), + accumulatedInputTokens: Number( + raw.accumulatedInputTokens ?? raw.accumulated_input_tokens ?? raw.inputTokens ?? 0 + ), + accumulatedOutputTokens: Number( + raw.accumulatedOutputTokens ?? raw.accumulated_output_tokens ?? raw.outputTokens ?? 0 + ), + accumulatedTotalTokens: Number( + raw.accumulatedTotalTokens ?? raw.accumulated_total_tokens ?? raw.totalTokens ?? 0 + ), + accumulatedCost: raw.accumulatedCost ?? raw.accumulated_cost ?? null + }; +} +function computeDeltaCostCents(previous, current, config = loadBillingConfig()) { + const prevCost = previous?.lastAccumulatedCost == null ? null : Number(previous.lastAccumulatedCost); + const currCost = current.accumulatedCost == null ? null : Number(current.accumulatedCost); + const margin = config.marginMultiplier ?? 1; + if (config.useBackendCost && prevCost != null && currCost != null && currCost >= prevCost) { + const deltaUsd = currCost - prevCost; + if (deltaUsd <= 0) return 0; + return Math.max(config.minBillCents, Math.ceil(deltaUsd * config.usdCnyRate * 100 * margin)); + } + if (config.useBackendCost && prevCost == null && currCost != null && currCost > 0) { + return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100 * margin)); + } + const prevIn = Number(previous?.lastInputTokens ?? 0); + const prevOut = Number(previous?.lastOutputTokens ?? 0); + const deltaIn = Math.max(0, current.accumulatedInputTokens - prevIn); + const deltaOut = Math.max(0, current.accumulatedOutputTokens - prevOut); + if (deltaIn === 0 && deltaOut === 0) return 0; + const raw = deltaIn / 1e3 * config.inputCentsPer1k + deltaOut / 1e3 * config.outputCentsPer1k; + if (raw <= 0) return 0; + return Math.max(config.minBillCents, Math.ceil(raw)); +} + +// billing-recharge.mjs +import crypto3 from "node:crypto"; +function loadRechargeConfig() { + const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? "500,1000,3000,5000,10000,20000").split(",").map((value) => Number(value.trim())).filter((value) => Number.isFinite(value) && value > 0); + return { + tiersCents: tiers.length ? tiers : [500, 1e3, 3e3, 5e3, 1e4, 2e4], + minRechargeCents: Number(process.env.H5_MIN_RECHARGE_CENTS ?? 500), + orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 15 * 60 * 1e3), + maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 3), + dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 2e5) + }; +} +function isAllowedRechargeAmount(amountCents, config = loadRechargeConfig()) { + return config.tiersCents.includes(Number(amountCents)); +} +function buildInsufficientBalancePayload(balanceCents, config = loadRechargeConfig()) { + return { + code: "INSUFFICIENT_BALANCE", + message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528", + balanceCents: Number(balanceCents ?? 0), + minRechargeCents: config.minRechargeCents, + suggestedTiers: config.tiersCents + }; +} +function createOutTradeNo() { + const stamp = Date.now().toString(36).toUpperCase(); + const rand = crypto3.randomBytes(4).toString("hex").toUpperCase(); + return `TK${stamp}${rand}`.slice(0, 32); +} +function mapOrderRow(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + amountCents: Number(row.amount_cents), + channel: row.channel, + status: row.status, + payMode: row.pay_mode, + outTradeNo: row.out_trade_no, + providerTxn: row.provider_txn, + paidAt: row.paid_at == null ? null : Number(row.paid_at), + expireAt: Number(row.expire_at), + createdAt: Number(row.created_at), + codeUrl: row.code_url ?? null, + h5Url: row.h5_url ?? null + }; +} +function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = loadRechargeConfig() } = {}) { + const expireStaleOrders = async (userId) => { + const now = Date.now(); + await pool.query( + `UPDATE h5_payment_orders + SET status = 'expired', updated_at = ? + WHERE user_id = ? AND status = 'pending' AND expire_at <= ?`, + [now, userId, now] + ); + }; + const countPendingOrders = async (userId) => { + const now = Date.now(); + const [rows] = await pool.query( + `SELECT COUNT(*) AS total + FROM h5_payment_orders + WHERE user_id = ? AND status = 'pending' AND expire_at > ?`, + [userId, now] + ); + return Number(rows[0]?.total ?? 0); + }; + const sumPaidToday = async (userId) => { + const start = /* @__PURE__ */ new Date(); + start.setHours(0, 0, 0, 0); + const [rows] = await pool.query( + `SELECT COALESCE(SUM(amount_cents), 0) AS total + FROM h5_payment_orders + WHERE user_id = ? AND status = 'paid' AND paid_at >= ?`, + [userId, start.getTime()] + ); + return Number(rows[0]?.total ?? 0); + }; + const getOrderById = async (orderId) => { + const [rows] = await pool.query(`SELECT * FROM h5_payment_orders WHERE id = ? LIMIT 1`, [ + orderId + ]); + return mapOrderRow(rows[0]); + }; + const getOrderByOutTradeNo = async (outTradeNo) => { + const [rows] = await pool.query( + `SELECT * FROM h5_payment_orders WHERE out_trade_no = ? LIMIT 1`, + [outTradeNo] + ); + return mapOrderRow(rows[0]); + }; + const getBillingConfig = async (userId) => { + const user = await userAuth2.getUserById(userId); + return { + wechatEnabled: Boolean(wechatPay?.enabled), + tiersCents: config.tiersCents, + minRechargeCents: config.minRechargeCents, + balanceCents: user ? Number(user.balance_cents ?? 0) : 0 + }; + }; + const createOrder = async ({ userId, amountCents, payScene, clientIp }) => { + const amount = Number(amountCents); + if (!isAllowedRechargeAmount(amount, config)) { + return { ok: false, message: "\u5145\u503C\u91D1\u989D\u4E0D\u5728\u5141\u8BB8\u6863\u4F4D\u5185" }; + } + if (!wechatPay?.enabled) { + return { ok: false, message: "\u5FAE\u4FE1\u652F\u4ED8\u5C1A\u672A\u914D\u7F6E\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" }; + } + const user = await userAuth2.getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + if (user.status === "disabled") return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528" }; + await expireStaleOrders(userId); + const pending = await countPendingOrders(userId); + if (pending >= config.maxPendingOrders) { + return { ok: false, message: "\u5F85\u652F\u4ED8\u8BA2\u5355\u8FC7\u591A\uFF0C\u8BF7\u5148\u5B8C\u6210\u6216\u7B49\u5F85\u8FC7\u671F\u540E\u518D\u8BD5" }; + } + const paidToday = await sumPaidToday(userId); + if (paidToday + amount > config.dailyLimitCents) { + return { ok: false, message: "\u5DF2\u8D85\u8FC7\u4ECA\u65E5\u5145\u503C\u4E0A\u9650\uFF0C\u8BF7\u660E\u65E5\u518D\u8BD5" }; + } + const now = Date.now(); + const orderId = crypto3.randomUUID(); + const outTradeNo = createOutTradeNo(); + const expireAt = now + config.orderTtlMs; + const description = `TKMind\u8D26\u6237\u5145\u503C\xA5${(amount / 100).toFixed(2)}`; + const mode = payScene === "jsapi" ? "jsapi" : payScene === "h5" ? "h5" : "native"; + let codeUrl = null; + let h5Url = null; + let jsapiParams = null; + try { + if (mode === "jsapi") { + const appId = wechatPay.appId; + if (!appId) { + return { ok: false, message: "\u5FAE\u4FE1\u652F\u4ED8 AppID \u672A\u914D\u7F6E" }; + } + const openid = await userAuth2.getWechatOpenidForUser(userId, appId); + if (!openid) { + return { + ok: false, + message: "\u8BF7\u5148\u7528\u5FAE\u4FE1\u767B\u5F55\u5E76\u7ED1\u5B9A\u8D26\u53F7\u540E\u518D\u5145\u503C" + }; + } + const result = await wechatPay.createJsapiOrder({ + outTradeNo, + description, + amountCents: amount, + clientIp, + openid + }); + jsapiParams = result.jsapiParams; + } else if (mode === "h5") { + const result = await wechatPay.createH5Order({ + outTradeNo, + description, + amountCents: amount, + clientIp + }); + h5Url = result.h5Url; + } else { + const result = await wechatPay.createNativeOrder({ + outTradeNo, + description, + amountCents: amount, + clientIp + }); + codeUrl = result.codeUrl; + } + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : "\u521B\u5EFA\u652F\u4ED8\u8BA2\u5355\u5931\u8D25" + }; + } + await pool.query( + `INSERT INTO h5_payment_orders + (id, user_id, amount_cents, channel, status, pay_mode, out_trade_no, + code_url, h5_url, expire_at, client_ip, created_at, updated_at) + VALUES (?, ?, ?, 'wechat', 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + orderId, + userId, + amount, + mode, + outTradeNo, + codeUrl, + h5Url, + expireAt, + clientIp ?? null, + now, + now + ] + ); + return { + ok: true, + order: { + id: orderId, + amountCents: amount, + status: "pending", + payMode: mode, + expireAt, + codeUrl, + h5Url, + jsapiParams + } + }; + }; + const fulfillOrder = async (order, transaction) => { + if (!order || order.status === "paid") { + return { ok: true, alreadyPaid: true }; + } + if (order.status !== "pending") { + return { ok: false, message: "\u8BA2\u5355\u72B6\u6001\u4E0D\u53EF\u652F\u4ED8" }; + } + const tradeState = transaction?.trade_state ?? (transaction?.result_code === "SUCCESS" ? "SUCCESS" : transaction?.result_code) ?? transaction?.trade_state_desc; + if (tradeState && tradeState !== "SUCCESS") { + return { ok: false, message: `\u652F\u4ED8\u672A\u6210\u529F: ${tradeState}` }; + } + const paidAmount = Number( + transaction?.amount?.total ?? transaction?.amount?.payer_total ?? transaction?.total_fee ?? 0 + ); + if (paidAmount !== order.amountCents) { + return { ok: false, message: "\u652F\u4ED8\u91D1\u989D\u4E0E\u8BA2\u5355\u4E0D\u4E00\u81F4" }; + } + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT * FROM h5_payment_orders WHERE id = ? FOR UPDATE`, + [order.id] + ); + const locked = mapOrderRow(rows[0]); + if (!locked) { + await conn.rollback(); + return { ok: false, message: "\u8BA2\u5355\u4E0D\u5B58\u5728" }; + } + if (locked.status === "paid") { + await conn.commit(); + return { ok: true, alreadyPaid: true }; + } + if (locked.status !== "pending") { + await conn.rollback(); + return { ok: false, message: "\u8BA2\u5355\u72B6\u6001\u4E0D\u53EF\u652F\u4ED8" }; + } + const recharge = await userAuth2.recharge(locked.userId, locked.amountCents, null, "\u7528\u6237\u81EA\u52A9\u5145\u503C", { + paymentOrderId: locked.id, + conn + }); + if (!recharge.ok) { + await conn.rollback(); + return recharge; + } + await conn.query( + `UPDATE h5_payment_orders + SET status = 'paid', provider_txn = ?, paid_at = ?, updated_at = ? + WHERE id = ?`, + [transaction.transaction_id ?? transaction.out_trade_no ?? null, now, now, locked.id] + ); + await conn.commit(); + return { ok: true, user: recharge.user, balanceCents: recharge.user.balanceCents }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + const handleWechatNotify = async ({ headers, body }) => { + const { transaction } = wechatPay.verifyNotify({ headers, body }); + const outTradeNo = transaction?.out_trade_no; + if (!outTradeNo) { + throw new Error("\u56DE\u8C03\u7F3A\u5C11\u5546\u6237\u8BA2\u5355\u53F7"); + } + const order = await getOrderByOutTradeNo(outTradeNo); + if (!order) { + throw new Error("\u8BA2\u5355\u4E0D\u5B58\u5728"); + } + return fulfillOrder(order, transaction); + }; + const getOrderForUser = async (userId, orderId) => { + const order = await getOrderById(orderId); + if (!order || order.userId !== userId) return null; + if (order.status === "pending" && order.expireAt <= Date.now()) { + await pool.query( + `UPDATE h5_payment_orders SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'pending'`, + [Date.now(), orderId] + ); + return { ...order, status: "expired" }; + } + return order; + }; + return { + config, + getBillingConfig, + createOrder, + getOrderForUser, + handleWechatNotify, + fulfillOrder, + getOrderByOutTradeNo + }; +} + +// user-space.mjs +import fs5 from "node:fs"; +import path6 from "node:path"; +var UPLOAD_ZONE_CODES = ["oa", "private", "public"]; +function resolveMindspaceStorageRoot(h5Root, env = process.env) { + return path6.resolve(env.MINDSPACE_STORAGE_ROOT ?? path6.join(h5Root, "data", "mindspace")); +} +function resolveUserWorkspaceRoot(h5Root, user) { + return resolvePublishDir(h5Root, user); +} +var WORKSPACE_HINTS_FILENAME2 = ".tkmindhints"; +var LEGACY_WORKSPACE_HINTS_FILENAME2 = ".goosehints"; +function renderUserSpaceBrandingBlock(userAddressName) { + const name = userAddressName || "\u7528\u6237"; + return `## \u54C1\u724C\u4E0E\u79F0\u547C\uFF08\u786C\u6027\uFF09 + +- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed +- \u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\uFF0C\u7528 **${name}** \u79F0\u547C\u7528\u6237\uFF08\u53EF\u8F85\u4EE5\u300C\u4F60/\u60A8\u300D\uFF09\uFF0C**\u7981\u6B62**\u628A\u7528\u6237\u53EB\u4F5C TKMind +- \u95EE\u5019\u793A\u4F8B\uFF1A\u300C${name}\uFF0C\u4E0B\u5348\u597D\u300D\u2014\u2014\u4E0D\u8981\u7528\u300CTKMind\uFF0C\u4E0B\u5348\u597D\u300D +- \u4E0D\u8981\u63CF\u8FF0\u672C\u5DE5\u4F5C\u533A\u4E3A\u300CRust goose \u9879\u76EE\u300D\u6216\u300Cgoose AI \u6846\u67B6\u300D +- \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E OA/\u79C1\u4EBA/\u516C\u5F00\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210 +`; +} +function resolveZoneDir(workspaceRoot, categoryCode) { + return path6.join(workspaceRoot, categoryCode); +} +function resolveZoneFilePath(workspaceRoot, categoryCode, filename) { + return path6.join(resolveZoneDir(workspaceRoot, categoryCode), filename); +} +function zoneLabel(categoryCode) { + return SYSTEM_CATEGORIES.find((item) => item.code === categoryCode)?.name ?? categoryCode; +} +function ensureUserZoneDirs(workspaceRoot) { + for (const code of UPLOAD_ZONE_CODES) { + fs5.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true }); + } +} +function mirrorAssetToZone2({ workspaceRoot, categoryCode, filename, sourcePath }) { + if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null; + if (!sourcePath || !fs5.existsSync(sourcePath)) return null; + ensureUserZoneDirs(workspaceRoot); + const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename); + fs5.mkdirSync(path6.dirname(dest), { recursive: true }); + fs5.copyFileSync(sourcePath, dest); + return dest; +} +function removeZoneMirror({ workspaceRoot, categoryCode, filename }) { + if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return; + const target = resolveZoneFilePath(workspaceRoot, categoryCode, filename); + if (fs5.existsSync(target)) fs5.unlinkSync(target); +} +function isPathInsideUserWorkspace(workspaceRoot, requestedPath) { + const base = path6.resolve(workspaceRoot); + const resolved = path6.resolve(requestedPath); + return resolved === base || resolved.startsWith(`${base}${path6.sep}`); +} +function renderUserSpaceHints({ username, workspaceRoot, displayName, slug }) { + const addressName = resolveUserAddressName({ displayName, username, slug }); + const zoneLines = UPLOAD_ZONE_CODES.map( + (code) => `- \`${code}/\` \u2014 ${zoneLabel(code)}\uFF08\u7528\u6237\u4E0A\u4F20\u843D\u5728\u6B64\u5206\u533A\uFF09` + ); + return `# TKMind \u7528\u6237\u7A7A\u95F4\u5206\u533A + +\u4F60\u662F\u7528\u6237 **${addressName}** \u7684\u4E13\u5C5E TKMind \u52A9\u624B\u3002\u5F53\u524D\u4F1A\u8BDD\u5DE5\u4F5C\u533A\uFF08\u552F\u4E00\u6587\u4EF6\u6839\u76EE\u5F55\uFF09\uFF1A +\`${workspaceRoot}\` + +${renderUserSpaceBrandingBlock(addressName)} +## \u5206\u533A\u89C4\u5219\uFF08\u786C\u6027\uFF09 + +\u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6**\u53EA\u4F1A**\u51FA\u73B0\u5728\u4E0B\u5217\u5B50\u76EE\u5F55\u4E4B\u4E00\uFF1A + +${zoneLines.join("\n")} + +## \u67E5\u627E / \u8BFB\u53D6\u6587\u4EF6 + +1. \u5728\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF0C\u4F8B\u5982 \`tree oa/\`\u3001\`find oa -name '*.csv'\`\u3001\`cat oa/\u6587\u4EF6\u540D.csv\` +2. \u7528\u6237\u8BF4\u300COA \u533A\u300D\u2192 \`oa/\`\uFF1B\u300C\u79C1\u4EBA\u533A\u300D\u2192 \`private/\`\uFF1B\u300C\u516C\u5F00\u533A\u300D\u2192 \`public/\` +3. **\u7981\u6B62**\u8BBF\u95EE\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}/\` \u6839\u76EE\u5F55\uFF08\u975E\u672C\u7528\u6237\uFF09\u3001\`data/mindspace/\`\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84 +4. **\u7981\u6B62**\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV\uFF1B\u516C\u7F51\u94FE\u63A5\u4EC5\u7528\u4E8E\u5206\u4EAB\u5DF2\u53D1\u5E03 HTML +5. \u627E\u4E0D\u5230\u65F6\u8BF4\u660E\u8BE5\u5206\u533A\u5185\u6CA1\u6709\u8BE5\u6587\u4EF6\uFF0C\u8BF7\u7528\u6237\u786E\u8BA4\u4E0A\u4F20\u5230\u4E86\u54EA\u4E2A\u5206\u533A + +## \u751F\u6210\u9875\u9762 + +- \u7528\u6237\u8981\u7F51\u9875 / HTML / \u62A5\u544A / \u5206\u4EAB\u94FE\u63A5\u65F6\uFF1A**\u4F60\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210**\uFF0C\u4E0D\u8981\u63A8\u7ED9\u7528\u6237\u624B\u52A8\u64CD\u4F5C +- \u5148 \`load_skill\` \u2192 \`static-page-publish\`\uFF0C\u518D\u4F7F\u7528 \`write_file\` \u521B\u5EFA \`public/\u9875\u9762\u540D.html\`\uFF08\u9700\u8981\u8C03\u6574\u5DF2\u6709\u9875\u9762\u65F6\u7528 \`edit_file\`\uFF09 +- \u5199\u5165 \`\` \u7684 **mindspace-cover** \u5143\u6570\u636E\uFF08\u8BE6\u89C1 \`.agents/skills/static-page-publish/SKILL.md\`\uFF09 +- \u4FDD\u5B58\u540E\u7ACB\u5373\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 \`[\u6807\u9898](URL)\`\uFF1B\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6 URL \u4E3A \`.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html\` +- **\u7981\u6B62**\u56DE\u590D\u300C\u8BF7\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E \`write_file\` / \`edit_file\` \u8C03\u7528\u5DF2\u5931\u8D25\u5E76\u8BF4\u660E\u5177\u4F53\u9519\u8BEF + +## \u5DE5\u4F5C\u533A\u6587\u4EF6\u4E0E OA \u754C\u9762 + +- \u5199\u5165 \`oa/\`\u3001\`private/\`\u3001\`public/\` \u6839\u76EE\u5F55\u7684\u652F\u6301\u7C7B\u578B\u6587\u4EF6\uFF08docx\u3001csv\u3001pdf\u3001\u56FE\u7247\u7B49\uFF09\u4F1A**\u81EA\u52A8\u540C\u6B65**\u5230 MindSpace \u8D44\u4EA7\u5E93 +- \u6253\u5F00\u5BF9\u5E94\u5206\u533A\u6216\u4FDD\u5B58\u6587\u4EF6\u540E\u4F1A\u51FA\u73B0\u5728\u754C\u9762\u4E2D\uFF0C\u53EF\u76F4\u63A5\u9884\u89C8\u6216\u4E0B\u8F7D +- \u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6\u4ECD\u4EE5\u754C\u9762\u5165\u5E93\u4E3A\u51C6\uFF0C\u5E76\u955C\u50CF\u5230\u4E0A\u8FF0\u5206\u533A +`; +} +function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slug, displayName }) { + const addressName = resolveUserAddressName({ displayName, username, slug }); + const zoneList = UPLOAD_ZONE_CODES.map((code) => `\`${code}/\`\uFF08${zoneLabel(code)}\uFF09`).join("\u3001"); + return [ + "## TKMind \u7528\u6237\u7A7A\u95F4\u5206\u533A\uFF08\u786C\u6027\u7EA6\u675F\uFF09", + "", + `- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\u7528 **${addressName}** \u79F0\u547C\u7528\u6237\uFF0C\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind`, + "- \u7981\u6B62\u79F0 goose / Goose / goosed \u6216\u300CRust goose \u9879\u76EE\u300D", + `- \u7528\u6237 **${addressName}** \u7684 Agent \u5DE5\u4F5C\u533A\uFF1A\`${workspaceRoot}\``, + `- \u7528\u6237\u4E0A\u4F20\u843D\u5728\u5206\u533A\u5B50\u76EE\u5F55\uFF1A${zoneList}\uFF08\u4F8B\u5982 OA \u6587\u4EF6\u5728 \`oa/\`\uFF09`, + "- **\u67E5\u627E\u6587\u4EF6**\uFF1A\u53EA\u5728\u4E0A\u8FF0\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF08\u5982 `oa/2025-12-06T13-34_export.csv`\uFF09", + "- **\u7981\u6B62**\uFF1A\u8BBF\u95EE\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001MindSpace \u6839\u76EE\u5F55\u3001data/mindspace \u5185\u90E8\u8DEF\u5F84\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84", + "- **\u7981\u6B62**\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV\uFF1B\u751F\u6210 HTML \u5199\u5165 `public/` \u5E76\u7ED9\u51FA\u516C\u7F51\u94FE\u63A5", + "- **\u751F\u6210\u9875\u9762**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file` / `edit_file` \u5199\u5165 `public/*.html`\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u6587\u4EF6", + publicBaseUrl && slug ? `- \u516C\u7F51 HTML \u524D\u7F00\uFF08\u516C\u5F00\u533A\uFF09\uFF1A\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`\uFF08\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6\u5206\u4EAB\u94FE\u63A5\u5FC5\u987B\u542B \`public/\`\uFF09` : null + ].filter(Boolean).join("\n"); +} +function ensureUserSpaceHints(workspaceRoot, context) { + const hintsPath = path6.join(workspaceRoot, WORKSPACE_HINTS_FILENAME2); + const legacyPath = path6.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME2); + const content = renderUserSpaceHints({ ...context, workspaceRoot }); + fs5.writeFileSync(hintsPath, content, "utf8"); + if (fs5.existsSync(legacyPath)) { + fs5.unlinkSync(legacyPath); + } + return hintsPath; +} +async function syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot) { + ensureUserZoneDirs(workspaceRoot); + const placeholders = UPLOAD_ZONE_CODES.map(() => "?").join(", "); + const [rows] = await pool.query( + `SELECT a.original_filename, c.category_code, v.storage_key + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})`, + [userId, ...UPLOAD_ZONE_CODES] + ); + for (const row of rows) { + const sourcePath = path6.join(storageRoot, row.storage_key); + mirrorAssetToZone2({ + workspaceRoot, + categoryCode: row.category_code, + filename: row.original_filename, + sourcePath + }); + } + return { workspaceRoot, mirrored: rows.length }; +} +async function ensureUserSpaceLayout({ + pool, + storageRoot, + userId, + username, + displayName, + publicBaseUrl, + slug, + workspaceRoot +}) { + ensureUserZoneDirs(workspaceRoot); + if (pool && userId) { + await syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot); + } + const context = { + username: username ?? slug, + displayName, + workspaceRoot, + slug, + publicBaseUrl + }; + ensureUserSpaceHints(workspaceRoot, context); + return { + workspaceRoot, + zonesRoot: workspaceRoot, + zonesPublicDir: resolveZoneDir(workspaceRoot, "public"), + constraints: buildUserSpaceConstraints(context), + ...context + }; +} + +// skills-registry.mjs +import fs6 from "node:fs"; +import path7 from "node:path"; +import { fileURLToPath as fileURLToPath4 } from "node:url"; +var __dirname3 = path7.dirname(fileURLToPath4(import.meta.url)); +var DEFAULT_USER_SKILLS = { + web: true, + search: true, + "schedule-assistant": true, + "form-builder": true, + "table-viewer": true, + "product-campaign-page": true, + [PUBLISH_SKILL_NAME]: false +}; +var USER_ROLE_SKILL_PRESETS = { + user: DEFAULT_USER_SKILLS, + creator: { + ...DEFAULT_USER_SKILLS, + [PUBLISH_SKILL_NAME]: true, + kanban: true, + timeline: true + }, + developer: { + ...DEFAULT_USER_SKILLS, + git: true, + "diff-viewer": true, + "code-playground": true, + "test-runner": true + } +}; +function parseSkillFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return { name: null, description: "" }; + const block = match[1]; + const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? null; + const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? ""; + return { name, description }; +} +function listPlatformSkillCatalog(h5Root = __dirname3) { + const skillsRoot = path7.join(h5Root, "skills"); + if (!fs6.existsSync(skillsRoot)) return []; + const catalog = []; + for (const entry of fs6.readdirSync(skillsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const skillPath = path7.join(skillsRoot, entry.name, "SKILL.md"); + if (!fs6.existsSync(skillPath)) continue; + const raw = fs6.readFileSync(skillPath, "utf8"); + const meta = parseSkillFrontmatter(raw); + const name = meta.name || entry.name; + catalog.push({ + name, + dirName: entry.name, + label: name, + description: meta.description || "\u5E73\u53F0\u901A\u7528\u6280\u80FD", + category: "platform", + requiresPublish: name === PUBLISH_SKILL_NAME + }); + } + return catalog.sort((a, b) => a.name.localeCompare(b.name)); +} +function isValidSkillName(catalog, name) { + return catalog.some((item) => item.name === name); +} +function normalizeSkillPatch(catalog, patch) { + const normalized = {}; + for (const [key, value] of Object.entries(patch ?? {})) { + if (!isValidSkillName(catalog, key)) continue; + normalized[key] = Boolean(value); + } + return normalized; +} +function resolveSkillMap(roleGrants, userOverrides, catalog) { + const resolved = {}; + for (const item of catalog) { + const key = item.name; + if (key in userOverrides) { + resolved[key] = userOverrides[key]; + } else if (key in roleGrants) { + resolved[key] = roleGrants[key]; + } else { + resolved[key] = DEFAULT_USER_SKILLS[key] ?? false; + } + } + return resolved; +} +function grantedSkillNames(skillMap) { + return Object.entries(skillMap).filter(([, enabled]) => enabled).map(([name]) => name); +} +function applySkillGrantsToCapabilities(capabilities, skillMap) { + const effective = { ...capabilities }; + const enabled = grantedSkillNames(skillMap); + if (enabled.length > 0) { + effective.skills = true; + } + if (enabled.includes(PUBLISH_SKILL_NAME)) { + effective.static_publish = true; + } + return effective; +} +function copySkillTree(srcDir, destDir) { + fs6.mkdirSync(destDir, { recursive: true }); + for (const entry of fs6.readdirSync(srcDir, { withFileTypes: true })) { + const from = path7.join(srcDir, entry.name); + const to = path7.join(destDir, entry.name); + if (entry.isDirectory()) { + copySkillTree(from, to); + } else { + fs6.copyFileSync(from, to); + } + } +} +function syncSkillsToWorkspace({ + h5Root, + publishDir, + skillMap, + catalog, + user, + publicBaseUrl +}) { + const enabled = new Set(grantedSkillNames(skillMap)); + const platformNames = new Set(catalog.map((item) => item.name)); + const agentsSkills = path7.join(publishDir, ".agents", "skills"); + if (fs6.existsSync(agentsSkills)) { + for (const entry of fs6.readdirSync(agentsSkills, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const skillName = entry.name; + const catalogItem = catalog.find((item) => item.name === skillName || item.dirName === skillName); + const resolvedName = catalogItem?.name ?? skillName; + if (platformNames.has(resolvedName) && !enabled.has(resolvedName)) { + fs6.rmSync(path7.join(agentsSkills, entry.name), { recursive: true, force: true }); + } + } + } + for (const item of catalog) { + if (!enabled.has(item.name)) continue; + const srcDir = path7.join(h5Root, "skills", item.dirName); + const destDir = path7.join(agentsSkills, item.name); + if (item.name === PUBLISH_SKILL_NAME && user) { + ensurePublishSkillInstalled(publishDir, { + slug: String(user.id).trim().toLowerCase(), + username: user.username ? String(user.username).trim().toLowerCase() : void 0, + publicBaseUrl, + publishDir + }); + continue; + } + if (fs6.existsSync(srcDir)) { + if (fs6.existsSync(destDir)) fs6.rmSync(destDir, { recursive: true, force: true }); + copySkillTree(srcDir, destDir); + } + } +} + +// user-auth.mjs +var USER_COOKIE = "tkmind_user_session"; +function safeEqual2(left, right) { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && crypto4.timingSafeEqual(a, b); +} +var PASSWORD_ALGORITHM_PBKDF2 = "pbkdf2-sha512"; +var PASSWORD_ALGORITHM_ARGON2ID = "argon2id"; +var ARGON2_MEMORY = 64 * 1024; +var ARGON2_PASSES = 3; +var ARGON2_PARALLELISM = 1; +var ARGON2_TAG_LENGTH = 32; +function hashPasswordPbkdf2(password, salt) { + return crypto4.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); +} +function hashPasswordArgon2id(password, salt) { + return argon2HashRawSync(password, { + salt: Buffer.from(salt, "hex"), + parallelism: ARGON2_PARALLELISM, + outputLen: ARGON2_TAG_LENGTH, + memoryCost: ARGON2_MEMORY, + timeCost: ARGON2_PASSES, + algorithm: Argon2Algorithm.Argon2id + }).toString("hex"); +} +function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) { + const salt = crypto4.randomBytes(16).toString("hex"); + if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { + return { + salt, + passwordHash: hashPasswordArgon2id(password, salt), + passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID + }; + } + return { + salt, + passwordHash: hashPasswordPbkdf2(password, salt), + passwordAlgorithm: PASSWORD_ALGORITHM_PBKDF2 + }; +} +function verifyPassword(password, row) { + const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2; + if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { + return safeEqual2(hashPasswordArgon2id(password, row.salt), row.password_hash); + } + return safeEqual2(hashPasswordPbkdf2(password, row.salt), row.password_hash); +} +function normalizeUsername(username) { + return username.trim().toLowerCase(); +} +function isValidUsername(username) { + return /^[a-z0-9_]{2,32}$/.test(username); +} +function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} +function hashSessionToken(token) { + return crypto4.createHash("sha256").update(token).digest("hex"); +} +function createUserAuth(pool, options = {}) { + const usersRoot = path8.resolve(options.usersRoot ?? "/tmp/tkmind_go_users"); + const h5Root = path8.resolve(options.h5Root ?? path8.join(usersRoot, "..")); + const env = options.env ?? process.env; + const storageRoot = resolveMindspaceStorageRoot(h5Root, env); + const publicBaseUrl = resolvePublicBaseUrl(env); + const skillCatalog = listPlatformSkillCatalog(h5Root); + const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500); + const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1e3); + const loginMaxFailures = Number(options.loginMaxFailures ?? 5); + const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1e3); + const persistSessions = options.persistSessions !== false && Boolean(pool); + let rechargeNotifier = typeof options.onRechargeNotification === "function" ? options.onRechargeNotification : null; + const subscriptionService2 = options.subscriptionService ?? null; + const sessions = /* @__PURE__ */ new Map(); + const loginFailures = /* @__PURE__ */ new Map(); + const pruneLoginFailures = (now = Date.now()) => { + for (const [key, state] of loginFailures) { + if (state.resetAt <= now) loginFailures.delete(key); + } + }; + const pruneSessions = (now = Date.now()) => { + for (const [token, session] of sessions) { + if (session.expiresAt <= now) sessions.delete(token); + } + }; + const storeSession = async (userId, role, token, now = Date.now()) => { + const expiresAt = now + sessionTtlMs; + sessions.set(token, { userId, role, expiresAt }); + if (!persistSessions) return expiresAt; + await pool.query( + `INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?)`, + [crypto4.randomUUID(), userId, hashSessionToken(token), expiresAt, now] + ); + return expiresAt; + }; + const revokeAllSessionsForUser = async (userId, now = Date.now()) => { + for (const [token, session] of sessions) { + if (session.userId === userId) sessions.delete(token); + } + if (!persistSessions) return; + await pool.query( + `UPDATE h5_login_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, + [now, userId] + ); + }; + const ensureWorkspace = (workspaceRoot) => { + fs7.mkdirSync(workspaceRoot, { recursive: true }); + }; + const isAdminRole = (user) => user?.role === "admin"; + const getUserById = async (userId) => { + const [rows] = await pool.query( + `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, + u.plan_type, u.workspace_root, + s.quota_bytes, s.used_bytes, s.reserved_bytes, + w.balance_cents, w.tokens_used, + (SELECT COALESCE(SUM(ABS(amount_cents)), 0) + FROM h5_billing_ledger l + WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents + FROM h5_users u + LEFT JOIN h5_user_spaces s ON s.user_id = u.id + LEFT JOIN h5_user_wallets w ON w.user_id = u.id + WHERE u.id = ?`, + [userId] + ); + return rows[0] ?? null; + }; + const publicUser = (row) => { + const balanceCents = Number(row.balance_cents ?? 0); + const spentCents = Number(row.spent_cents ?? 0); + const base = { + id: row.id, + username: row.username, + slug: row.slug ?? row.username, + email: row.email ?? null, + displayName: row.display_name, + role: row.role, + status: row.status, + planType: row.plan_type ?? "free", + workspaceRoot: row.workspace_root, + balanceCents, + totalCreditCents: balanceCents + spentCents, + tokensUsed: Number(row.tokens_used ?? 0), + spaceQuotaBytes: Number(row.quota_bytes ?? 0), + spaceUsedBytes: Number(row.used_bytes ?? 0), + spaceReservedBytes: Number(row.reserved_bytes ?? 0), + spaceAvailableBytes: Math.max( + 0, + Number(row.quota_bytes ?? 0) - Number(row.used_bytes ?? 0) - Number(row.reserved_bytes ?? 0) + ) + }; + const publishKey = row.id; + return { + ...base, + publishSlug: publishKey, + publishUrl: `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/`, + publishSkillName: PUBLISH_SKILL_NAME + }; + }; + const publishLayoutFor = async (user, { migrateLegacy = true } = {}) => { + const web = ensureUserPublishLayout({ + h5Root, + publicBaseUrl, + user, + legacyUsersRoot: migrateLegacy ? usersRoot : null + }); + const space = await ensureUserSpaceLayout({ + pool, + storageRoot, + userId: user.id, + username: user.username ?? web.slug, + displayName: user.displayName, + publicBaseUrl, + slug: web.slug, + workspaceRoot: web.publishDir + }); + const hintsContext = { + slug: web.slug, + username: user.username ?? web.username, + displayName: user.displayName, + publicBaseUrl, + publishDir: web.publishDir + }; + ensurePublishSkillInstalled(web.publishDir, hintsContext); + ensureWorkspaceHintsInstalled(web.publishDir, hintsContext); + const legacyPublishDir = resolveLegacyPublishDir(h5Root, user); + if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs7.existsSync(legacyPublishDir)) { + ensureWorkspaceHintsInstalled(legacyPublishDir, { ...hintsContext, publishDir: legacyPublishDir }); + } + ensureUserMemoryProfile(web.publishDir, { + userId: user.id, + displayName: user.displayName ?? user.display_name, + username: user.username ?? web.username, + slug: web.slug + }); + return { + ...web, + ...space, + publishDir: web.publishDir, + constraints: web.constraints + }; + }; + const listSkillGrants = async (subjectType, subjectId) => { + const [rows] = await pool.query( + `SELECT skill_name, enabled + FROM h5_user_skill_grants + WHERE subject_type = ? AND subject_id = ?`, + [subjectType, subjectId] + ); + return Object.fromEntries(rows.map((row) => [row.skill_name, Boolean(row.enabled)])); + }; + const resolveUserSkillMap = async (user) => { + if (!user || user.role === "admin") { + return Object.fromEntries(skillCatalog.map((item) => [item.name, true])); + } + const roleDefaults = await listSkillGrants("role", "user"); + const userOverrides = await listSkillGrants("user", user.id); + return resolveSkillMap(roleDefaults, userOverrides, skillCatalog); + }; + const syncUserSkillsForUser = async (user) => { + if (!user || user.role === "admin") return; + const layout = await syncUserPublishWorkspace(user); + const skillMap = await resolveUserSkillMap(user); + syncSkillsToWorkspace({ + h5Root, + publishDir: layout?.publishDir, + skillMap, + catalog: skillCatalog, + user, + publicBaseUrl + }); + }; + const syncUserPublishWorkspace = async (user) => { + if (!user) return null; + const layout = await publishLayoutFor(user); + const current = path8.resolve(user.workspace_root); + const target = path8.resolve(layout.publishDir); + if (current !== target) { + const now = Date.now(); + await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ + layout.publishDir, + now, + user.id + ]); + await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]); + await pool.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [user.id, layout.publishDir] + ); + } + return layout; + }; + const recordSignupBonus = async (conn, userId, amountCents, now) => { + const amount = Number(amountCents); + if (!Number.isFinite(amount) || amount <= 0) return; + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'adjust', ?, 0, '\u65B0\u7528\u6237\u8D60\u9001', NULL, ?)`, + [userId, amount, now] + ); + }; + const register = async ({ username, password, displayName, email }) => { + const normalized = normalizeUsername(username); + if (!isValidUsername(normalized)) { + return { ok: false, message: "\u7528\u6237\u540D\u4EC5\u652F\u6301 2-32 \u4F4D\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF" }; + } + if (!password || password.length < 6) { + return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" }; + } + if (!email || !isValidEmail(email.trim())) { + return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" }; + } + const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); + const userId = crypto4.randomUUID(); + const layout = await publishLayoutFor({ id: userId, username: normalized }); + const workspaceRoot = layout.publishDir; + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO h5_users + (id, username, slug, email, display_name, salt, password_hash, password_algorithm, + role, status, plan_type, workspace_root, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, ?, ?)`, + [ + userId, + normalized, + normalized, + email?.trim().toLowerCase() || null, + displayName?.trim() || normalized, + salt, + passwordHash, + passwordAlgorithm, + workspaceRoot, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?)`, + [userId, defaultSignupBalanceCents, now] + ); + await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now); + await conn.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [userId, workspaceRoot] + ); + await initializeDefaultSpace(conn, userId, { + quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024), + now + }); + await conn.commit(); + ensureWorkspace(workspaceRoot); + ensureUserMemoryProfile(workspaceRoot, { + userId, + displayName: displayName?.trim() || normalized, + username: normalized, + slug: normalized + }); + if (subscriptionService2) { + subscriptionService2.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => { + }); + } + const user = await getUserById(userId); + return { ok: true, user: publicUser(user) }; + } catch (err) { + await conn.rollback(); + if (err?.code === "ER_DUP_ENTRY") { + return { ok: false, message: "\u7528\u6237\u540D\u3001\u4E3B\u9875\u5730\u5740\u6216\u90AE\u7BB1\u5DF2\u5B58\u5728" }; + } + throw err; + } finally { + conn.release(); + } + }; + const login = async ({ username, password, ip = "unknown", now = Date.now() }) => { + pruneLoginFailures(now); + const normalized = normalizeUsername(username); + const failureKey = `${ip}:${normalized}`; + const failure = loginFailures.get(failureKey); + if (failure && failure.count >= loginMaxFailures && failure.resetAt > now) { + return { + ok: false, + message: "\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5", + retryAfterMs: failure.resetAt - now + }; + } + const [rows] = await pool.query( + `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, + u.plan_type, u.workspace_root, + u.salt, u.password_hash, u.password_algorithm, w.balance_cents, w.tokens_used + FROM h5_users u + LEFT JOIN h5_user_wallets w ON w.user_id = u.id + WHERE u.username = ?`, + [normalized] + ); + const row = rows[0]; + if (!row) { + const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs }; + current.count += 1; + loginFailures.set(failureKey, current); + return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" }; + } + if (!verifyPassword(password, row)) { + const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs }; + current.count += 1; + loginFailures.set(failureKey, current); + return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" }; + } + if (row.status === "disabled") { + return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" }; + } + if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) { + const nextPassword = createPasswordRecord(password); + await pool.query( + `UPDATE h5_users + SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? + WHERE id = ?`, + [nextPassword.salt, nextPassword.passwordHash, nextPassword.passwordAlgorithm, now, row.id] + ); + row.salt = nextPassword.salt; + row.password_hash = nextPassword.passwordHash; + row.password_algorithm = nextPassword.passwordAlgorithm; + } + loginFailures.delete(failureKey); + const token = crypto4.randomBytes(32).toString("base64url"); + await storeSession(row.id, row.role, token, now); + return { ok: true, token, user: publicUser(row) }; + }; + const resetPassword = async ({ username, email, password }) => { + const normalized = normalizeUsername(username); + if (!isValidUsername(normalized)) { + return { ok: false, message: "\u7528\u6237\u540D\u6216\u90AE\u7BB1\u4E0D\u6B63\u786E" }; + } + if (!email || !isValidEmail(email.trim())) { + return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" }; + } + if (!password || password.length < 6) { + return { ok: false, message: "\u65B0\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" }; + } + const [rows] = await pool.query( + `SELECT id, email, status FROM h5_users WHERE username = ? LIMIT 1`, + [normalized] + ); + const row = rows[0]; + const normalizedEmail = email.trim().toLowerCase(); + if (!row || (row.email ?? "").toLowerCase() !== normalizedEmail) { + return { ok: false, message: "\u7528\u6237\u540D\u6216\u90AE\u7BB1\u4E0D\u6B63\u786E" }; + } + if (row.status === "disabled") { + return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" }; + } + const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); + const now = Date.now(); + await pool.query( + `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, + [salt, passwordHash, passwordAlgorithm, now, row.id] + ); + await revokeAllSessionsForUser(row.id, now); + return { ok: true }; + }; + const verify = async (token, now = Date.now()) => { + if (!token) return null; + pruneSessions(now); + const cached = sessions.get(token); + if (cached) { + if (cached.expiresAt <= now) { + sessions.delete(token); + return null; + } + const user = await getUserById(cached.userId); + if (!user || user.status === "disabled") { + await revoke(token, now); + return null; + } + cached.expiresAt = now + sessionTtlMs; + if (persistSessions) { + await pool.query( + `UPDATE h5_login_sessions + SET expires_at = ? + WHERE token_hash = ? AND revoked_at IS NULL`, + [cached.expiresAt, hashSessionToken(token)] + ); + } + return cached; + } + if (!persistSessions) return null; + const tokenHash = hashSessionToken(token); + const [rows] = await pool.query( + `SELECT s.user_id, s.expires_at, u.role, u.status + FROM h5_login_sessions s + JOIN h5_users u ON u.id = s.user_id + WHERE s.token_hash = ? AND s.revoked_at IS NULL + LIMIT 1`, + [tokenHash] + ); + const row = rows[0]; + if (!row || Number(row.expires_at ?? 0) <= now || row.status === "disabled") { + if (row) await revoke(token, now); + return null; + } + const expiresAt = now + sessionTtlMs; + await pool.query( + `UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, + [expiresAt, tokenHash] + ); + const session = { userId: row.user_id, role: row.role, expiresAt }; + sessions.set(token, session); + return session; + }; + const revoke = async (token, now = Date.now()) => { + if (!token) return; + sessions.delete(token); + if (!persistSessions) return; + await pool.query( + `UPDATE h5_login_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, + [now, hashSessionToken(token)] + ); + }; + const getMe = async (token) => { + const session = await verify(token); + if (!session) return null; + const user = await getUserById(session.userId); + if (!user) return null; + return publicUser(user); + }; + const listPathGrants = async (userId) => { + const [rows] = await pool.query( + `SELECT path, mode FROM h5_user_path_grants WHERE user_id = ? ORDER BY path`, + [userId] + ); + return rows.map((row) => ({ path: row.path, mode: row.mode })); + }; + const resolveWorkingDir = async (userId) => { + const user = await getUserById(userId); + if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728"); + const layout = await syncUserPublishWorkspace(user); + return layout.publishDir; + }; + const getUserPublishLayout = async (userId) => { + const user = await getUserById(userId); + if (!user) return null; + return syncUserPublishWorkspace(user); + }; + const isPathAllowed = async (userId, requestedPath) => { + const user = await getUserById(userId); + if (!user) return false; + if (isAdminRole(user)) return true; + const layout = await publishLayoutFor(user, { migrateLegacy: false }); + return isPathInsideUserWorkspace(layout.publishDir, requestedPath); + }; + const repairAllUserPublishDirs = async () => { + const [rows] = await pool.query( + `SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'` + ); + for (const row of rows) { + await syncUserSkillsForUser(row); + } + }; + const seedRoleSkillDefaults = async () => { + const now = Date.now(); + for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) { + await pool.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('role', 'user', ?, ?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, + [name, enabled ? 1 : 0, now] + ); + } + }; + const registerAgentSession = async (userId, agentSessionId, goosedNode = 0) => { + await pool.query( + `INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, created_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), goosed_node = VALUES(goosed_node)`, + [agentSessionId, userId, goosedNode, Date.now()] + ); + }; + const getSessionNode = async (agentSessionId) => { + const [rows] = await pool.query( + `SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [agentSessionId] + ); + return rows[0]?.goosed_node ?? 0; + }; + const ownsSession = async (userId, agentSessionId) => { + const [rows] = await pool.query( + `SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`, + [agentSessionId, userId] + ); + return rows.length > 0; + }; + const listOwnedSessionIds = async (userId) => { + const [rows] = await pool.query( + `SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`, + [userId] + ); + return new Set(rows.map((row) => row.agent_session_id)); + }; + const unregisterAgentSession = async (userId, agentSessionId) => { + await pool.query( + `DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`, + [agentSessionId, userId] + ); + }; + const canUseChat = async (userId) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + if (user.status === "disabled") { + return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528" }; + } + if (user.status === "suspended" && !isAdminRole(user)) { + return { ok: false, message: "\u8D26\u6237\u5DF2\u6682\u505C" }; + } + if (isAdminRole(user)) { + return { ok: true, balanceCents: Number(user.balance_cents ?? 0) }; + } + if (subscriptionService2) { + const sub = await subscriptionService2.getActiveSubscription(userId); + if (sub) { + const unlimited = sub.periodTokensLimit === 0; + const hasQuota = unlimited || sub.periodTokensUsed < sub.periodTokensLimit; + const balanceCents2 = Number(user.balance_cents ?? 0); + if (hasQuota) { + return { ok: true, balanceCents: balanceCents2, subscription: sub }; + } + if (balanceCents2 > 0) { + return { ok: true, balanceCents: balanceCents2, subscription: sub, overQuota: true }; + } + return { + ok: false, + message: "\u672C\u6708\u989D\u5EA6\u5DF2\u7528\u5B8C\uFF0C\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u6216\u5347\u7EA7\u5957\u9910", + ...buildInsufficientBalancePayload(balanceCents2, loadRechargeConfig()) + }; + } + } + const balanceCents = Number(user.balance_cents ?? 0); + if (balanceCents <= 0) { + return { + ok: false, + message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528", + ...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()) + }; + } + return { ok: true, balanceCents }; + }; + const listUsers = async ({ page = 1, pageSize = 20, search = "", role = "", status = "" } = {}) => { + const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safePageSize; + const params = []; + const clauses = []; + if (search) { + clauses.push("(u.username LIKE ? OR u.display_name LIKE ?)"); + params.push(`%${search}%`, `%${search}%`); + } + if (role) { + clauses.push("u.role = ?"); + params.push(role); + } + if (status) { + clauses.push("u.status = ?"); + params.push(status); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const [[{ total }]] = await pool.query( + `SELECT COUNT(*) AS total FROM h5_users u ${where}`, + params + ); + const [rows] = await pool.query( + `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, + u.plan_type, u.workspace_root, + s.quota_bytes, s.used_bytes, s.reserved_bytes, + u.created_at, u.updated_at, w.balance_cents, w.tokens_used + FROM h5_users u + LEFT JOIN h5_user_spaces s ON s.user_id = u.id + LEFT JOIN h5_user_wallets w ON w.user_id = u.id + ${where} + ORDER BY u.created_at DESC + LIMIT ${safePageSize} OFFSET ${offset}`, + params + ); + return { + users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })), + total: Number(total), + page: safePage, + pageSize: safePageSize + }; + }; + const getUserPublic = async (userId) => { + const user = await getUserById(userId); + return user ? publicUser(user) : null; + }; + const createUser = async ({ + username, + password, + displayName, + workspaceRoot, + balanceCents, + role = "user", + email + }) => { + const normalized = normalizeUsername(username); + if (!isValidUsername(normalized)) { + return { ok: false, message: "\u7528\u6237\u540D\u683C\u5F0F\u65E0\u6548" }; + } + if (!password || password.length < 6) { + return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" }; + } + const isAdmin = role === "admin"; + const userId = crypto4.randomUUID(); + const root = (await publishLayoutFor({ id: userId, username: normalized })).publishDir; + const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO h5_users + (id, username, slug, email, display_name, salt, password_hash, password_algorithm, + role, status, plan_type, workspace_root, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, ?)`, + [ + userId, + normalized, + normalized, + email?.trim().toLowerCase() || null, + displayName?.trim() || normalized, + salt, + passwordHash, + passwordAlgorithm, + isAdmin ? "admin" : "user", + root, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?)`, + [userId, Number(balanceCents ?? defaultSignupBalanceCents), now] + ); + await conn.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [userId, root] + ); + if (!isAdmin) { + await initializeDefaultSpace(conn, userId, { + quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024), + now + }); + } + await conn.commit(); + ensureWorkspace(root); + if (subscriptionService2 && !isAdmin) { + subscriptionService2.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => { + }); + } + const user = await getUserById(userId); + return { ok: true, user: publicUser(user) }; + } catch (err) { + await conn.rollback(); + if (err?.code === "ER_DUP_ENTRY") { + return { ok: false, message: "\u7528\u6237\u540D\u5DF2\u5B58\u5728" }; + } + throw err; + } finally { + conn.release(); + } + }; + const updateUser = async (userId, patch) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + const now = Date.now(); + const fields = []; + const values = []; + if (patch.displayName !== void 0) { + fields.push("display_name = ?"); + values.push(patch.displayName.trim() || user.username); + } + if (patch.status !== void 0) { + fields.push("status = ?"); + values.push(patch.status); + } + if (patch.workspaceRoot !== void 0) { + const root = path8.resolve(patch.workspaceRoot); + fields.push("workspace_root = ?"); + values.push(root); + ensureWorkspace(root); + await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]); + await pool.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [userId, root] + ); + } + if (patch.role !== void 0) { + fields.push("role = ?"); + values.push(patch.role === "admin" ? "admin" : "user"); + } + if (fields.length > 0) { + fields.push("updated_at = ?"); + values.push(now, userId); + await pool.query(`UPDATE h5_users SET ${fields.join(", ")} WHERE id = ?`, values); + } + if (patch.status === "disabled" || patch.status === "suspended") { + await revokeAllSessionsForUser(userId, now); + } + if (patch.balanceCents !== void 0) { + await pool.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?) + ON DUPLICATE KEY UPDATE balance_cents = VALUES(balance_cents), updated_at = VALUES(updated_at)`, + [userId, Number(patch.balanceCents), now] + ); + } + if (patch.spaceQuotaBytes !== void 0) { + const quotaBytes = Math.floor(Number(patch.spaceQuotaBytes)); + if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) { + return { ok: false, message: "\u7A7A\u95F4\u5927\u5C0F\u65E0\u6548" }; + } + const [spaceRows] = await pool.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes + FROM h5_user_spaces + WHERE user_id = ? + LIMIT 1`, + [userId] + ); + const currentSpace = spaceRows[0]; + const occupiedBytes = Number(currentSpace?.used_bytes ?? 0) + Number(currentSpace?.reserved_bytes ?? 0); + if (quotaBytes < occupiedBytes) { + return { + ok: false, + message: `\u7A7A\u95F4\u4E0D\u80FD\u5C0F\u4E8E\u5DF2\u4F7F\u7528\u5BB9\u91CF ${Math.ceil(occupiedBytes / 1024 / 1024)} MB` + }; + } + if (currentSpace?.id) { + await pool.query( + `UPDATE h5_user_spaces + SET quota_bytes = ?, updated_at = ? + WHERE user_id = ?`, + [quotaBytes, now, userId] + ); + } else { + await initializeDefaultSpace(pool, userId, { + quotaBytes, + now + }); + } + } + const updated = await getUserById(userId); + return { ok: true, user: publicUser(updated) }; + }; + const purchaseSpaceQuota = async (userId, sizeMb) => { + const purchaseMb = Math.floor(Number(sizeMb)); + if (!Number.isFinite(purchaseMb) || purchaseMb <= 0) { + return { ok: false, message: "\u6269\u5BB9\u5927\u5C0F\u65E0\u6548" }; + } + const deltaBytes = purchaseMb * 1024 * 1024; + const costCents = purchaseMb * 200; + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [spaceRows] = await conn.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes + FROM h5_user_spaces + WHERE user_id = ? + LIMIT 1 + FOR UPDATE`, + [userId] + ); + if (!spaceRows[0]) { + await initializeDefaultSpace(conn, userId, { now }); + } + const [walletRows] = await conn.query( + `SELECT balance_cents + FROM h5_user_wallets + WHERE user_id = ? + FOR UPDATE`, + [userId] + ); + const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); + if (balanceCents < costCents) { + await conn.rollback(); + return { + ok: false, + code: "INSUFFICIENT_BALANCE", + message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5148\u5145\u503C\u540E\u518D\u8D2D\u4E70\u7A7A\u95F4", + balanceCents, + minRechargeCents: Math.max(500, costCents - balanceCents), + suggestedTiers: loadRechargeConfig().tiersCents + }; + } + await conn.query( + `UPDATE h5_user_wallets + SET balance_cents = balance_cents - ?, updated_at = ? + WHERE user_id = ?`, + [costCents, now, userId] + ); + await conn.query( + `UPDATE h5_user_spaces + SET quota_bytes = quota_bytes + ?, updated_at = ? + WHERE user_id = ?`, + [deltaBytes, now, userId] + ); + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, + [userId, costCents, `space_purchase:${purchaseMb}MB`, now] + ); + await conn.query( + `INSERT INTO h5_user_notifications + (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) + VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`, + [ + crypto4.randomUUID(), + userId, + "\u7A7A\u95F4\u6269\u5BB9\u6210\u529F", + `\u5DF2\u8D2D\u4E70 ${purchaseMb} MB \u7A7A\u95F4\uFF0C\u652F\u4ED8 \xA5${(costCents / 100).toFixed(2)}\u3002`, + JSON.stringify({ purchaseMb, deltaBytes, costCents }), + now, + now + ] + ); + await conn.commit(); + const [updatedSpaceRows] = await pool.query( + `SELECT quota_bytes, used_bytes, reserved_bytes + FROM h5_user_spaces + WHERE user_id = ? + LIMIT 1`, + [userId] + ); + const updatedSpace = updatedSpaceRows[0] ?? {}; + const updatedUser = await getUserById(userId); + return { + ok: true, + balanceCents: Number(updatedUser?.balance_cents ?? Math.max(0, balanceCents - costCents)), + quota: { + quotaBytes: Number(updatedSpace.quota_bytes ?? 0), + usedBytes: Number(updatedSpace.used_bytes ?? 0), + reservedBytes: Number(updatedSpace.reserved_bytes ?? 0), + availableBytes: Math.max( + 0, + Number(updatedSpace.quota_bytes ?? 0) - Number(updatedSpace.used_bytes ?? 0) - Number(updatedSpace.reserved_bytes ?? 0) + ) + } + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + const recharge = async (userId, amountCents, operatorId, note = "", options2 = {}) => { + const amount = Number(amountCents); + if (!Number.isFinite(amount) || amount <= 0) { + return { ok: false, message: "\u5145\u503C\u91D1\u989D\u65E0\u6548" }; + } + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + const paymentOrderId = options2.paymentOrderId ?? null; + const ledgerNote = paymentOrderId ? `order:${paymentOrderId}` : note || (operatorId ? "\u7BA1\u7406\u5458\u5145\u503C" : "\u8D26\u6237\u5145\u503C"); + const rechargeType = paymentOrderId ? "self_recharge" : operatorId ? "admin_recharge" : "recharge"; + const now = Date.now(); + const ownsConnection = !options2.conn; + const conn = options2.conn ?? await pool.getConnection(); + try { + if (ownsConnection) await conn.beginTransaction(); + await conn.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?) + ON DUPLICATE KEY UPDATE + balance_cents = balance_cents + VALUES(balance_cents), + updated_at = VALUES(updated_at)`, + [userId, amount, now] + ); + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'recharge', ?, 0, ?, ?, ?)`, + [userId, amount, ledgerNote, operatorId, now] + ); + const title = paymentOrderId ? "\u5145\u503C\u6210\u529F" : operatorId ? "\u7BA1\u7406\u5458\u5DF2\u5145\u503C" : "\u8D26\u6237\u5145\u503C\u6210\u529F"; + const body = paymentOrderId ? `\u4F60\u5DF2\u6210\u529F\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002` : operatorId ? `\u7BA1\u7406\u5458\u5DF2\u4E3A\u4F60\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002` : `\u4F60\u7684\u8D26\u6237\u5DF2\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002`; + await conn.query( + `INSERT INTO h5_user_notifications + (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) + VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`, + [ + crypto4.randomUUID(), + userId, + rechargeType, + title, + body, + JSON.stringify({ + amountCents: amount, + operatorId: operatorId ?? null, + paymentOrderId + }), + now, + now + ] + ); + if (user.status === "suspended") { + await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [ + now, + userId + ]); + } + if (ownsConnection) await conn.commit(); + const updated = await getUserById(userId); + if (rechargeNotifier) { + try { + await rechargeNotifier({ + userId, + amountCents: amount, + operatorId: operatorId ?? null, + paymentOrderId, + notificationType: rechargeType, + title, + body, + user: publicUser(updated) + }); + } catch (err) { + console.warn( + "Recharge notifier failed:", + err instanceof Error ? err.message : String(err) + ); + } + } + return { ok: true, user: publicUser(updated) }; + } catch (err) { + if (ownsConnection) await conn.rollback(); + throw err; + } finally { + if (ownsConnection) conn.release(); + } + }; + const getBillingState = async (agentSessionId) => { + const [rows] = await pool.query( + `SELECT agent_session_id, user_id, last_accumulated_cost, last_input_tokens, + last_output_tokens, updated_at + FROM h5_session_billing_state + WHERE agent_session_id = ?`, + [agentSessionId] + ); + const row = rows[0]; + if (!row) return null; + return { + agentSessionId: row.agent_session_id, + userId: row.user_id, + lastAccumulatedCost: row.last_accumulated_cost, + lastInputTokens: Number(row.last_input_tokens ?? 0), + lastOutputTokens: Number(row.last_output_tokens ?? 0), + updatedAt: Number(row.updated_at) + }; + }; + const billSessionUsage = async (userId, agentSessionId, tokenStateRaw, requestId = null) => { + const user = await getUserById(userId); + if (isAdminRole(user)) { + return { + ok: true, + costCents: 0, + balanceCents: Number(user?.balance_cents ?? 0), + tokensUsed: Number(user?.tokens_used ?? 0), + deltaInputTokens: 0, + deltaOutputTokens: 0 + }; + } + const tokenState = normalizeTokenState(tokenStateRaw); + const previous = await getBillingState(agentSessionId); + if (previous && tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)) { + const user2 = await getUserById(userId); + return { + ok: true, + costCents: 0, + balanceCents: user2 ? Number(user2.balance_cents) : null, + tokensUsed: user2 ? Number(user2.tokens_used ?? 0) : null, + deltaInputTokens: 0, + deltaOutputTokens: 0 + }; + } + const config = loadBillingConfig(); + let costCents = computeDeltaCostCents(previous, tokenState, config); + const deltaIn = Math.max( + 0, + tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0) + ); + const deltaOut = Math.max( + 0, + tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0) + ); + const deltaTokens = deltaIn + deltaOut; + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO h5_session_billing_state + (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + last_accumulated_cost = VALUES(last_accumulated_cost), + last_input_tokens = VALUES(last_input_tokens), + last_output_tokens = VALUES(last_output_tokens), + updated_at = VALUES(updated_at)`, + [ + agentSessionId, + userId, + tokenState.accumulatedCost, + tokenState.accumulatedInputTokens, + tokenState.accumulatedOutputTokens, + now + ] + ); + if (costCents > 0 && subscriptionService2) { + const coverage = await subscriptionService2.consumeQuota(userId, deltaTokens, conn); + if (coverage.fullyCovers) { + costCents = 0; + } else if (coverage.overageRate < 1) { + costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate)); + } + } + let balanceAfter = null; + let tokensUsedAfter = null; + if (costCents > 0) { + const [walletRows] = await conn.query( + `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, + [userId] + ); + const wallet = walletRows[0]; + if (!wallet) { + await conn.rollback(); + return { ok: false, message: "\u94B1\u5305\u4E0D\u5B58\u5728", costCents: 0 }; + } + const currentBalance = Number(wallet.balance_cents ?? 0); + const nextBalance = Math.max(0, currentBalance - costCents); + balanceAfter = nextBalance; + tokensUsedAfter = Number(wallet.tokens_used ?? 0) + deltaTokens; + await conn.query( + `UPDATE h5_user_wallets + SET balance_cents = ?, tokens_used = tokens_used + ?, updated_at = ? + WHERE user_id = ?`, + [nextBalance, deltaTokens, now, userId] + ); + await conn.query( + `INSERT INTO h5_usage_records + (user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, agentSessionId, requestId, deltaIn, deltaOut, costCents, nextBalance, now] + ); + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, session_id, note, operator_id, created_at) + VALUES (?, 'deduct', ?, ?, ?, ?, NULL, ?)`, + [ + userId, + -costCents, + deltaTokens, + agentSessionId, + `\u5BF9\u8BDD\u6263\u8D39 input=${deltaIn} output=${deltaOut}`, + now + ] + ); + if (nextBalance <= 0) { + await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [ + now, + userId + ]); + } + } else { + const user2 = await getUserById(userId); + balanceAfter = user2 ? Number(user2.balance_cents) : null; + tokensUsedAfter = user2 ? Number(user2.tokens_used ?? 0) : null; + } + await conn.commit(); + return { + ok: true, + costCents, + balanceCents: balanceAfter, + tokensUsed: tokensUsedAfter, + deltaInputTokens: deltaIn, + deltaOutputTokens: deltaOut + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + const listUsageRecords = async ({ userId = null, page = 1, pageSize = 20, limit = null } = {}) => { + if (limit !== null) { + const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); + const params2 = []; + const where2 = userId ? "WHERE r.user_id = ?" : ""; + if (userId) params2.push(userId); + const [rows2] = await pool.query( + `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, + r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at + FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id + ${where2} ORDER BY r.created_at DESC LIMIT ${safeLimit}`, + params2 + ); + return rows2.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })); + } + const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safePageSize; + const params = []; + const where = userId ? "WHERE r.user_id = ?" : ""; + if (userId) params.push(userId); + const [[{ total }]] = await pool.query( + `SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`, + params + ); + const [rows] = await pool.query( + `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, + r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at + FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id + ${where} + ORDER BY r.created_at DESC + LIMIT ${safePageSize} OFFSET ${offset}`, + params + ); + return { + records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })), + total: Number(total), + page: safePage, + pageSize: safePageSize + }; + }; + const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = null } = {}) => { + const buildWhere = (params) => { + const clauses = []; + if (userId) { + clauses.push("l.user_id = ?"); + params.push(userId); + } + if (Array.isArray(types) && types.length) { + clauses.push(`l.type IN (${types.map(() => "?").join(", ")})`); + params.push(...types); + } + return clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + }; + const mapRow = (row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, type: row.type, amountCents: Number(row.amount_cents), tokens: Number(row.tokens), sessionId: row.session_id, note: row.note, createdAt: Number(row.created_at) }); + if (limit !== null) { + const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); + const params = []; + const where2 = buildWhere(params); + const [rows2] = await pool.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where2} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params); + return rows2.map(mapRow); + } + const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safePageSize; + const countParams = []; + const where = buildWhere(countParams); + const [[{ total }]] = await pool.query( + `SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`, + countParams + ); + const dataParams = []; + const dataWhere = buildWhere(dataParams); + const [rows] = await pool.query( + `SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, + l.session_id, l.note, l.created_at + FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id + ${dataWhere} + ORDER BY l.created_at DESC + LIMIT ${safePageSize} OFFSET ${offset}`, + dataParams + ); + return { entries: rows.map(mapRow), total: Number(total), page: safePage, pageSize: safePageSize }; + }; + const getAdminSummary = async () => { + const since24h = Date.now() - 24 * 60 * 60 * 1e3; + const [userRows] = await pool.query( + `SELECT u.id, u.username, u.display_name, u.role, u.status, + COALESCE(w.balance_cents, 0) AS balance_cents + FROM h5_users u + LEFT JOIN h5_user_wallets w ON w.user_id = u.id` + ); + let total = 0; + let active = 0; + let lowBalance = 0; + let totalBalanceCents = 0; + const lowBalanceUsers = []; + for (const row of userRows) { + total += 1; + if (row.status === "active") active += 1; + const balanceCents = Number(row.balance_cents); + totalBalanceCents += balanceCents; + if (row.role === "user" && balanceCents <= 0) { + lowBalance += 1; + if (lowBalanceUsers.length < 8) { + lowBalanceUsers.push({ + id: row.id, + username: row.username, + displayName: row.display_name, + balanceCents + }); + } + } + } + const [[usage24h]] = await pool.query( + `SELECT COUNT(*) AS count, COALESCE(SUM(cost_cents), 0) AS cost_cents + FROM h5_usage_records + WHERE created_at >= ?`, + [since24h] + ); + const recentUsage = await listUsageRecords({ limit: 8 }); + const recentLedger = await listBillingLedger({ limit: 8 }); + return { + users: { total, active, lowBalance, totalBalanceCents }, + usage24h: { + count: Number(usage24h.count), + costCents: Number(usage24h.cost_cents) + }, + lowBalanceUsers, + recentUsage, + recentLedger + }; + }; + const syncAdminPassword = async () => { + const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? "admin"); + const adminPassword = process.env.H5_ADMIN_PASSWORD; + if (!adminPassword) return; + const [rows] = await pool.query( + `SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`, + [adminUsername] + ); + if (rows.length === 0) return; + const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword); + const now = Date.now(); + await pool.query( + `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, + [salt, passwordHash, passwordAlgorithm, now, rows[0].id] + ); + }; + const seedRoleCapabilityDefaults = async () => { + const now = Date.now(); + for (const [key, allowed] of Object.entries(DEFAULT_USER_CAPABILITIES)) { + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', 'user', ?, ?, ?) + ON DUPLICATE KEY UPDATE capability_key = capability_key`, + [key, allowed ? 1 : 0, now] + ); + } + }; + const upgradeMemoryStoreCapability = async () => { + const now = Date.now(); + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', 'user', 'memory_store', 1, ?) + ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, + [now] + ); + }; + const upgradeDefaultUserCapabilities = async () => { + const now = Date.now(); + for (const key of ["skills", "chat_recall"]) { + if (!DEFAULT_USER_CAPABILITIES[key]) continue; + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', 'user', ?, 1, ?) + ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, + [key, now] + ); + } + }; + const upgradeDefaultUserSkills = async () => { + const now = Date.now(); + for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) { + if (!enabled) continue; + await pool.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('role', 'user', ?, 1, ?) + ON DUPLICATE KEY UPDATE enabled = 1, updated_at = VALUES(updated_at)`, + [name, now] + ); + } + }; + const serializePolicyValue = (key, value) => { + const def = POLICY_CATALOG.find((item) => item.key === key); + if (def?.type === "boolean") return value ? "true" : "false"; + return String(value); + }; + const parsePolicyValue = (key, raw) => { + const def = POLICY_CATALOG.find((item) => item.key === key); + if (def?.type === "boolean") return raw === "true" || raw === "1"; + return raw; + }; + const seedRolePolicyDefaults = async () => { + const now = Date.now(); + for (const [key, value] of Object.entries(DEFAULT_USER_POLICIES)) { + await pool.query( + `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) + VALUES ('role', 'user', ?, ?, ?) + ON DUPLICATE KEY UPDATE policy_key = policy_key`, + [key, serializePolicyValue(key, value), now] + ); + } + }; + const listPolicyEntries = async (subjectType, subjectId) => { + const [rows] = await pool.query( + `SELECT policy_key, policy_value + FROM h5_user_policies + WHERE subject_type = ? AND subject_id = ?`, + [subjectType, subjectId] + ); + return Object.fromEntries( + rows.map((row) => [row.policy_key, parsePolicyValue(row.policy_key, row.policy_value)]) + ); + }; + const resolveUserPolicies = async (user) => { + if (!user || user.role === "admin") { + return { unrestricted: true, policies: {} }; + } + const roleDefaults = await listPolicyEntries("role", "user"); + const userOverrides = await listPolicyEntries("user", user.id); + return { + unrestricted: false, + policies: resolvePolicies(roleDefaults, userOverrides) + }; + }; + const listCapabilityGrants = async (subjectType, subjectId) => { + const [rows] = await pool.query( + `SELECT capability_key, allowed + FROM h5_capability_grants + WHERE subject_type = ? AND subject_id = ?`, + [subjectType, subjectId] + ); + return Object.fromEntries( + rows.map((row) => [row.capability_key, Boolean(row.allowed)]) + ); + }; + const resolveUserCapabilities = async (user) => { + if (!user) return { unrestricted: true, capabilities: {} }; + if (user.role === "admin") { + const skillMap2 = Object.fromEntries(skillCatalog.map((item) => [item.name, true])); + return { + unrestricted: true, + capabilities: Object.fromEntries(catalogKeys().map((key) => [key, true])), + skills: skillMap2, + grantedSkills: grantedSkillNames(skillMap2) + }; + } + const roleDefaults = await listCapabilityGrants("role", "user"); + const userOverrides = await listCapabilityGrants("user", user.id); + const capabilities = {}; + for (const key of catalogKeys()) { + if (key in userOverrides) { + capabilities[key] = userOverrides[key]; + } else if (key in roleDefaults) { + capabilities[key] = roleDefaults[key]; + } else { + capabilities[key] = DEFAULT_USER_CAPABILITIES[key] ?? false; + } + } + const skillMap = await resolveUserSkillMap(user); + const withSkills = applySkillGrantsToCapabilities(capabilities, skillMap); + return { + unrestricted: false, + capabilities: clampUserCapabilities(withSkills), + skills: skillMap, + grantedSkills: grantedSkillNames(skillMap) + }; + }; + const getAgentSessionPolicy = async (userId) => { + const user = await getUserById(userId); + if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728"); + const capabilityState = await resolveUserCapabilities(user); + const policyState = await resolveUserPolicies(user); + await syncUserSkillsForUser(user); + if (capabilityState.unrestricted) { + return { + ...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true }), + policies: {}, + unrestricted: true + }; + } + const effectiveCapabilities = applyPoliciesToCapabilities( + capabilityState.capabilities, + policyState.policies + ); + let sandboxMcp = null; + if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) { + try { + const layout = await publishLayoutFor(user, { migrateLegacy: false }); + sandboxMcp = { + serverPath: resolveSandboxMcpServerPath(), + sandboxRoot: layout.publishDir, + userId: user.id + }; + } catch (err) { + console.warn("[getAgentSessionPolicy] sandbox MCP setup failed, falling back:", err?.message); + } + } + return { + ...buildAgentExtensionPolicy(effectiveCapabilities, { + unrestricted: false, + policies: policyState.policies, + sandboxMcp + }), + capabilities: effectiveCapabilities, + policies: policyState.policies, + unrestricted: false + }; + }; + const getRoleCapabilities = async (role = "user") => { + const roleDefaults = await listCapabilityGrants("role", role); + const capabilities = {}; + for (const key of catalogKeys()) { + capabilities[key] = key in roleDefaults ? roleDefaults[key] : DEFAULT_USER_CAPABILITIES[key] ?? false; + } + return { role, capabilities: clampUserCapabilities(capabilities) }; + }; + const setRoleCapabilities = async (role, patch) => { + if (role !== "user") { + return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u6743\u9650" }; + } + const normalized = normalizeCapabilityPatch(patch); + const now = Date.now(); + for (const [key, allowed] of Object.entries(normalized)) { + const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed; + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, + [role, key, effectiveAllowed ? 1 : 0, now] + ); + } + return { ok: true, ...await getRoleCapabilities(role) }; + }; + const getUserCapabilities = async (userId) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + const resolved = await resolveUserCapabilities(user); + const overrides = await listCapabilityGrants("user", userId); + return { + ok: true, + userId, + role: user.role, + unrestricted: resolved.unrestricted, + capabilities: resolved.capabilities, + overrides + }; + }; + const setUserCapabilities = async (userId, patch) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + if (user.role === "admin") { + return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u80FD\u529B\u9650\u5236" }; + } + const normalized = normalizeCapabilityPatch(patch); + const now = Date.now(); + for (const [key, allowed] of Object.entries(normalized)) { + if (!isValidCapabilityKey(key)) continue; + const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed; + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('user', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, + [userId, key, effectiveAllowed ? 1 : 0, now] + ); + } + return getUserCapabilities(userId); + }; + const clearUserCapabilityOverrides = async (userId) => { + await pool.query( + `DELETE FROM h5_capability_grants WHERE subject_type = 'user' AND subject_id = ?`, + [userId] + ); + return getUserCapabilities(userId); + }; + const getRolePolicies = async (role = "user") => { + const roleDefaults = await listPolicyEntries("role", role); + const policies = resolvePolicies(roleDefaults, {}); + return { role, policies }; + }; + const setRolePolicies = async (role, patch) => { + if (role !== "user") { + return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u7B56\u7565" }; + } + const normalized = normalizePolicyPatch(patch); + const now = Date.now(); + for (const [key, value] of Object.entries(normalized)) { + await pool.query( + `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) + VALUES ('role', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, + [role, key, serializePolicyValue(key, value), now] + ); + } + return { ok: true, ...await getRolePolicies(role) }; + }; + const getUserPolicies = async (userId) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + const policyState = await resolveUserPolicies(user); + const overrides = await listPolicyEntries("user", userId); + return { + ok: true, + userId, + role: user.role, + unrestricted: policyState.unrestricted, + policies: policyState.policies, + overrides + }; + }; + const setUserPolicies = async (userId, patch) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + if (user.role === "admin") { + return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u7B56\u7565\u9650\u5236" }; + } + const normalized = normalizePolicyPatch(patch); + const now = Date.now(); + for (const [key, value] of Object.entries(normalized)) { + if (!policyKeys().includes(key)) continue; + await pool.query( + `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) + VALUES ('user', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, + [userId, key, serializePolicyValue(key, value), now] + ); + } + return getUserPolicies(userId); + }; + const clearUserPolicyOverrides = async (userId) => { + await pool.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [ + userId + ]); + return getUserPolicies(userId); + }; + const getRoleSkills = async (role = "user") => { + const roleDefaults = await listSkillGrants("role", role); + const skills = resolveSkillMap(roleDefaults, {}, skillCatalog); + return { role, skills }; + }; + const setRoleSkills = async (role, patch) => { + if (role !== "user") { + return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u6280\u80FD" }; + } + const normalized = normalizeSkillPatch(skillCatalog, patch); + const now = Date.now(); + for (const [name, enabled] of Object.entries(normalized)) { + await pool.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('role', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, + [role, name, enabled ? 1 : 0, now] + ); + } + const [users] = await pool.query(`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`); + for (const row of users) { + await syncUserSkillsForUser(row); + } + return { ok: true, ...await getRoleSkills(role) }; + }; + const getUserSkills = async (userId) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + const skills = await resolveUserSkillMap(user); + const overrides = await listSkillGrants("user", userId); + return { + ok: true, + userId, + role: user.role, + skills, + grantedSkills: grantedSkillNames(skills), + overrides + }; + }; + const setUserSkills = async (userId, patch) => { + const user = await getUserById(userId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + if (user.role === "admin") { + return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u6280\u80FD\u9650\u5236" }; + } + const normalized = normalizeSkillPatch(skillCatalog, patch); + const now = Date.now(); + for (const [name, enabled] of Object.entries(normalized)) { + await pool.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('user', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, + [userId, name, enabled ? 1 : 0, now] + ); + } + await syncUserSkillsForUser(user); + return getUserSkills(userId); + }; + const clearUserSkillOverrides = async (userId) => { + await pool.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [ + userId + ]); + const user = await getUserById(userId); + if (user) await syncUserSkillsForUser(user); + return getUserSkills(userId); + }; + const ensureAdminUser = async () => { + const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? "admin"); + const adminPassword = process.env.H5_ADMIN_PASSWORD; + const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + adminUsername + ]); + if (rows.length === 0) { + if (!adminPassword) return; + await createUser({ + username: adminUsername, + password: adminPassword, + displayName: "\u7BA1\u7406\u5458", + balanceCents: 999999999, + role: "admin" + }); + } else { + const adminId = rows[0].id; + const now = Date.now(); + const adminLayout = await publishLayoutFor({ + id: adminId, + username: adminUsername, + displayName: "\u7BA1\u7406\u5458" + }); + await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ + adminLayout.publishDir, + now, + adminId + ]); + await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]); + await pool.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [adminId, adminLayout.publishDir] + ); + await pool.query( + `UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`, + [999999999, now, adminId] + ); + } + if (adminPassword) { + await syncAdminPassword(); + } + await seedRoleCapabilityDefaults(); + await upgradeMemoryStoreCapability(); + await upgradeDefaultUserCapabilities(); + await seedRolePolicyDefaults(); + await seedRoleSkillDefaults(); + await upgradeDefaultUserSkills(); + await repairAllUserPublishDirs(); + }; + const PENDING_BIND_TTL_MS = 15 * 60 * 1e3; + const issueUserSession = async (userId, role, now = Date.now()) => { + const token = crypto4.randomBytes(32).toString("base64url"); + await storeSession(userId, role, token, now); + return token; + }; + const findBindingByOpenid = async (appId, openid) => { + const [rows] = await pool.query( + `SELECT wi.user_id, u.status + FROM h5_user_wechat_identities wi + JOIN h5_users u ON u.id = wi.user_id + WHERE wi.app_id = ? AND wi.openid = ? + LIMIT 1`, + [appId, openid] + ); + return rows[0] ?? null; + }; + const findBindingByUnionid = async (unionid) => { + if (!unionid) return null; + const [rows] = await pool.query( + `SELECT wi.user_id, wi.app_id, u.status + FROM h5_user_wechat_identities wi + JOIN h5_users u ON u.id = wi.user_id + WHERE wi.unionid = ? + LIMIT 1`, + [unionid] + ); + return rows[0] ?? null; + }; + const getWechatBindingForUser = async (userId, appId) => { + const [rows] = await pool.query( + `SELECT id, nickname, avatar_url, last_login_at, created_at + FROM h5_user_wechat_identities + WHERE user_id = ? AND app_id = ? + LIMIT 1`, + [userId, appId] + ); + return rows[0] ?? null; + }; + const getWechatOpenidForUser = async (userId, appId) => { + const [rows] = await pool.query( + `SELECT openid + FROM h5_user_wechat_identities + WHERE user_id = ? AND app_id = ? + LIMIT 1`, + [userId, appId] + ); + return rows[0]?.openid ?? null; + }; + const findWechatUserByOpenid = async (appId, openid) => { + const [rows] = await pool.query( + `SELECT wi.user_id, wi.nickname, u.username, u.slug, u.display_name, u.status + FROM h5_user_wechat_identities wi + JOIN h5_users u ON u.id = wi.user_id + WHERE wi.app_id = ? AND wi.openid = ? + LIMIT 1`, + [appId, openid] + ); + return rows[0] ? { + userId: rows[0].user_id, + status: rows[0].status, + nickname: rows[0].nickname, + username: rows[0].username, + slug: rows[0].slug, + displayName: rows[0].display_name + } : null; + }; + const getWechatAgentRoute = async (appId, openid) => { + const [rows] = await pool.query( + `SELECT id, user_id, agent_session_id, status, created_at, updated_at + FROM h5_wechat_agent_routes + WHERE app_id = ? AND openid = ? + LIMIT 1`, + [appId, openid] + ); + const row = rows[0]; + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + agentSessionId: row.agent_session_id, + status: row.status, + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0) + }; + }; + const upsertWechatAgentRoute = async ({ + userId, + appId, + openid, + agentSessionId, + status = "active", + now = Date.now() + }) => { + const id = crypto4.randomUUID(); + await pool.query( + `INSERT INTO h5_wechat_agent_routes + (id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + user_id = VALUES(user_id), + agent_session_id = VALUES(agent_session_id), + status = VALUES(status), + updated_at = VALUES(updated_at)`, + [id, userId, appId, openid, agentSessionId, status, now, now] + ); + const route = await getWechatAgentRoute(appId, openid); + return route?.id ?? id; + }; + const clearWechatAgentRoute = async (appId, openid) => { + await pool.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [ + appId, + openid + ]); + }; + const recordWechatMpMessage = async ({ + appId, + openid, + msgId, + now = Date.now() + }) => { + if (!appId || !openid || !msgId) return { inserted: true }; + const [result] = await pool.query( + `INSERT IGNORE INTO h5_wechat_mp_messages + (app_id, openid, msg_id, status, created_at, updated_at) + VALUES (?, ?, ?, 'processing', ?, ?)`, + [appId, openid, String(msgId), now, now] + ); + if (Number(result?.affectedRows ?? 0) > 0) return { inserted: true }; + const retryCutoff = now - 10 * 60 * 1e3; + const [retryResult] = await pool.query( + `UPDATE h5_wechat_mp_messages + SET status = 'processing', agent_session_id = NULL, updated_at = ? + WHERE app_id = ? AND openid = ? AND msg_id = ? + AND (status = 'failed' OR (status = 'processing' AND updated_at < ?))`, + [now, appId, openid, String(msgId), retryCutoff] + ); + return { + inserted: Number(retryResult?.affectedRows ?? 0) > 0, + duplicate: Number(retryResult?.affectedRows ?? 0) === 0 + }; + }; + const finishWechatMpMessage = async ({ + appId, + openid, + msgId, + status = "done", + agentSessionId = null, + now = Date.now() + }) => { + if (!appId || !openid || !msgId) return; + const safeStatus = status === "failed" ? "failed" : "done"; + await pool.query( + `UPDATE h5_wechat_mp_messages + SET status = ?, agent_session_id = COALESCE(?, agent_session_id), updated_at = ? + WHERE app_id = ? AND openid = ? AND msg_id = ?`, + [safeStatus, agentSessionId, now, appId, openid, String(msgId)] + ); + }; + const insertWechatMpMessageDetail = async ({ + appId, + openid, + userId = null, + msgId = null, + msgType, + displayText = "", + agentText = "", + mediaId = null, + mediaUrl = null, + mediaPublicUrl = null, + mediaFormat = null, + locationLat = null, + locationLng = null, + locationLabel = null, + linkUrl = null, + linkTitle = null, + rawXmlHash = null, + rawJson = null, + now = Date.now() + }) => { + if (!appId || !openid || !msgType) return null; + const id = crypto4.randomUUID(); + await pool.query( + `INSERT INTO h5_wechat_mp_message_details + (id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text, + media_id, media_url, media_public_url, media_format, + location_lat, location_lng, location_label, + link_url, link_title, raw_xml_hash, raw_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + appId, + openid, + userId, + msgId ? String(msgId) : null, + String(msgType), + displayText || null, + agentText || null, + mediaId, + mediaUrl, + mediaPublicUrl, + mediaFormat, + locationLat, + locationLng, + locationLabel, + linkUrl, + linkTitle, + rawXmlHash, + rawJson ? JSON.stringify(rawJson) : null, + now + ] + ); + return id; + }; + const bindWechatToUser = async ({ + userId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now = Date.now() + }) => { + const existingOpenid = await findBindingByOpenid(appId, openid); + if (existingOpenid && existingOpenid.user_id !== userId) { + return { ok: false, message: "\u8BE5\u5FAE\u4FE1\u5DF2\u7ED1\u5B9A\u5176\u4ED6\u8D26\u53F7\uFF0C\u8BF7\u5148\u7528\u8BE5\u8D26\u53F7\u767B\u5F55" }; + } + const existingUserBind = await getWechatBindingForUser(userId, appId); + if (existingUserBind) { + return { ok: false, message: "\u4F60\u7684\u8D26\u53F7\u5DF2\u7ED1\u5B9A\u5176\u4ED6\u5FAE\u4FE1\uFF0C\u9700\u5148\u89E3\u7ED1\u540E\u518D\u8BD5" }; + } + try { + await pool.query( + `INSERT INTO h5_user_wechat_identities + (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + crypto4.randomUUID(), + userId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now, + now, + now + ] + ); + return { ok: true }; + } catch (err) { + if (err?.code === "ER_DUP_ENTRY") { + return { ok: false, message: "\u5FAE\u4FE1\u7ED1\u5B9A\u51B2\u7A81\uFF0C\u8BF7\u91CD\u8BD5" }; + } + throw err; + } + }; + const touchWechatIdentity = async ({ + appId, + openid, + unionid, + nickname, + avatarUrl, + now = Date.now() + }) => { + await pool.query( + `UPDATE h5_user_wechat_identities + SET nickname = COALESCE(?, nickname), + avatar_url = COALESCE(?, avatar_url), + unionid = COALESCE(?, unionid), + last_login_at = ?, + updated_at = ? + WHERE app_id = ? AND openid = ?`, + [nickname, avatarUrl, unionid, now, now, appId, openid] + ); + }; + const pruneWechatPendingBinds = async (now = Date.now()) => { + await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]); + }; + const createWechatPendingBind = async ({ + appId, + openid, + unionid, + nickname, + avatarUrl, + returnTo = "/", + utmSource = null, + utmMedium = null, + utmCampaign = null, + now = Date.now() + }) => { + await pruneWechatPendingBinds(now); + const token = crypto4.randomBytes(24).toString("base64url"); + await pool.query( + `INSERT INTO h5_wechat_pending_binds + (token, app_id, openid, unionid, nickname, avatar_url, return_to, + utm_source, utm_medium, utm_campaign, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + token, + appId, + openid, + unionid, + nickname, + avatarUrl, + returnTo, + utmSource, + utmMedium, + utmCampaign, + now + PENDING_BIND_TTL_MS, + now + ] + ); + return token; + }; + const getWechatPendingBind = async (token, now = Date.now()) => { + if (!token) return null; + await pruneWechatPendingBinds(now); + const [rows] = await pool.query( + `SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to, + utm_source, utm_medium, utm_campaign, expires_at + FROM h5_wechat_pending_binds + WHERE token = ? + LIMIT 1`, + [token] + ); + const row = rows[0]; + if (!row || Number(row.expires_at) <= now) return null; + return row; + }; + const consumeWechatPendingBind = async (token) => { + await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]); + }; + const loginBoundWechatUser = async ({ + userId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now = Date.now() + }) => { + await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now }); + const user = await getUserById(userId); + if (!user || user.status === "disabled") { + return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" }; + } + const token = await issueUserSession(user.id, user.role, now); + return { ok: true, token, user: publicUser(user), isNewUser: false }; + }; + const generateWechatUsername = async (openid) => { + const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); + const suffix = cleaned.slice(-8) || crypto4.randomBytes(4).toString("hex"); + let candidate = `wx_${suffix}`.slice(0, 32); + if (!isValidUsername(candidate)) { + candidate = `wx_${crypto4.randomBytes(4).toString("hex")}`; + } + for (let attempt = 0; attempt < 8; attempt += 1) { + const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + candidate + ]); + if (!rows[0]) return candidate; + candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto4.randomBytes(2).toString("hex")}`.slice( + 0, + 32 + ); + } + return `wx_${crypto4.randomBytes(6).toString("hex")}`.slice(0, 32); + }; + const registerViaWechat = async ({ + appId, + openid, + unionid, + nickname, + avatarUrl, + now = Date.now() + }) => { + const normalized = await generateWechatUsername(openid); + const randomPassword = crypto4.randomBytes(24).toString("base64url"); + const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword); + const userId = crypto4.randomUUID(); + const layout = await publishLayoutFor({ id: userId, username: normalized }); + const workspaceRoot = layout.publishDir; + const displayName = nickname?.trim() || "\u5FAE\u4FE1\u7528\u6237"; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO h5_users + (id, username, slug, email, display_name, salt, password_hash, password_algorithm, + role, status, plan_type, workspace_root, signup_source, created_at, updated_at) + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', ?, ?)`, + [ + userId, + normalized, + normalized, + displayName, + salt, + passwordHash, + passwordAlgorithm, + workspaceRoot, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?)`, + [userId, defaultSignupBalanceCents, now] + ); + await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now); + await conn.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [userId, workspaceRoot] + ); + await conn.query( + `INSERT INTO h5_user_wechat_identities + (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + crypto4.randomUUID(), + userId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now, + now, + now + ] + ); + await initializeDefaultSpace(conn, userId, { + quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024), + now + }); + await conn.commit(); + ensureWorkspace(workspaceRoot); + ensureUserMemoryProfile(workspaceRoot, { + userId, + displayName, + username: normalized, + slug: normalized + }); + if (subscriptionService2) { + subscriptionService2.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => { + }); + } + const user = await getUserById(userId); + return { ok: true, user: publicUser(user) }; + } catch (err) { + await conn.rollback(); + if (err?.code === "ER_DUP_ENTRY") { + return { ok: false, message: "\u5FAE\u4FE1\u8D26\u53F7\u6CE8\u518C\u51B2\u7A81\uFF0C\u8BF7\u91CD\u8BD5" }; + } + throw err; + } finally { + conn.release(); + } + }; + const resolveWechatAuth = async ({ + appId, + openid, + unionid, + nickname, + avatarUrl, + intent = "login", + bindUserId = null, + returnTo = "/", + utmSource = null, + utmMedium = null, + utmCampaign = null, + now = Date.now() + }) => { + let binding = await findBindingByOpenid(appId, openid); + if (!binding && unionid) { + const unionBinding = await findBindingByUnionid(unionid); + if (unionBinding) { + const linked = await bindWechatToUser({ + userId: unionBinding.user_id, + appId, + openid, + unionid, + nickname, + avatarUrl, + now + }); + if (!linked.ok) return linked; + binding = { user_id: unionBinding.user_id, status: unionBinding.status }; + } + } + if (binding) { + if (binding.status === "disabled") { + return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" }; + } + return loginBoundWechatUser({ + userId: binding.user_id, + appId, + openid, + unionid, + nickname, + avatarUrl, + now + }).then((result) => result.ok ? { ...result, action: "login" } : result); + } + if (intent === "bind" && bindUserId) { + const user = await getUserById(bindUserId); + if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" }; + if (user.status === "disabled") { + return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" }; + } + const bound = await bindWechatToUser({ + userId: bindUserId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now + }); + if (!bound.ok) return bound; + const token = await issueUserSession(user.id, user.role, now); + return { + ok: true, + action: "login", + token, + user: publicUser(user), + isNewUser: false, + bound: true + }; + } + if (intent === "register") { + const registered = await registerViaWechat({ + appId, + openid, + unionid, + nickname, + avatarUrl, + now + }); + if (!registered.ok) return registered; + const token = await issueUserSession(registered.user.id, registered.user.role, now); + return { + ok: true, + action: "login", + token, + user: registered.user, + isNewUser: true + }; + } + const pendingToken = await createWechatPendingBind({ + appId, + openid, + unionid, + nickname, + avatarUrl, + returnTo, + utmSource, + utmMedium, + utmCampaign, + now + }); + return { + ok: true, + action: "binding_gate", + pendingToken, + wechatProfile: { + nickname: nickname ?? null, + avatarUrl: avatarUrl ?? null + }, + returnTo, + utmSource, + utmMedium, + utmCampaign + }; + }; + const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => { + const pending = await getWechatPendingBind(pendingToken, now); + if (!pending) { + return { ok: false, message: "\u7ED1\u5B9A\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5FAE\u4FE1\u767B\u5F55" }; + } + const registered = await registerViaWechat({ + appId: pending.app_id, + openid: pending.openid, + unionid: pending.unionid, + nickname: pending.nickname, + avatarUrl: pending.avatar_url, + now + }); + if (!registered.ok) return registered; + await consumeWechatPendingBind(pendingToken); + const token = await issueUserSession(registered.user.id, registered.user.role, now); + return { + ok: true, + token, + user: registered.user, + isNewUser: true, + returnTo: pending.return_to || "/", + utmSource: pending.utm_source, + utmMedium: pending.utm_medium, + utmCampaign: pending.utm_campaign + }; + }; + const completeWechatBindAccount = async ({ + pendingToken, + username, + password, + ip = "unknown", + now = Date.now() + }) => { + const pending = await getWechatPendingBind(pendingToken, now); + if (!pending) { + return { ok: false, message: "\u7ED1\u5B9A\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5FAE\u4FE1\u767B\u5F55" }; + } + const loginResult = await login({ username, password, ip, now }); + if (!loginResult.ok) return loginResult; + const bound = await bindWechatToUser({ + userId: loginResult.user.id, + appId: pending.app_id, + openid: pending.openid, + unionid: pending.unionid, + nickname: pending.nickname, + avatarUrl: pending.avatar_url, + now + }); + if (!bound.ok) return bound; + await consumeWechatPendingBind(pendingToken); + return { + ok: true, + token: loginResult.token, + user: loginResult.user, + isNewUser: false, + bound: true, + returnTo: pending.return_to || "/" + }; + }; + const getWechatBindingStatus = async (userId, appId) => { + const row = await getWechatBindingForUser(userId, appId); + if (!row) return { bound: false }; + return { + bound: true, + nickname: row.nickname, + avatarUrl: row.avatar_url, + lastLoginAt: Number(row.last_login_at), + boundAt: Number(row.created_at) + }; + }; + const loginByWechat = async (params) => { + const result = await resolveWechatAuth({ ...params, intent: "login" }); + if (!result.ok) return result; + if (result.action === "binding_gate") { + return { ok: false, message: "\u9700\u8981\u5B8C\u6210\u8D26\u53F7\u7ED1\u5B9A" }; + } + return result; + }; + return { + USER_COOKIE, + register, + login, + loginByWechat, + resolveWechatAuth, + completeWechatRegister, + completeWechatBindAccount, + getWechatPendingBind, + getWechatBindingStatus, + getWechatOpenidForUser, + setRechargeNotifier(callback) { + rechargeNotifier = typeof callback === "function" ? callback : null; + }, + findWechatUserByOpenid, + getWechatAgentRoute, + upsertWechatAgentRoute, + clearWechatAgentRoute, + recordWechatMpMessage, + finishWechatMpMessage, + insertWechatMpMessageDetail, + resetPassword, + verify, + revoke, + revokeAllSessionsForUser, + getMe, + listPathGrants, + resolveWorkingDir, + getUserPublishLayout, + isPathAllowed, + repairAllUserPublishDirs, + registerAgentSession, + getSessionNode, + unregisterAgentSession, + ownsSession, + listOwnedSessionIds, + canUseChat, + getUserById, + getUserPublic, + listUsers, + createUser, + updateUser, + purchaseSpaceQuota, + recharge, + billSessionUsage, + listUsageRecords, + listBillingLedger, + getAdminSummary, + ensureAdminUser, + seedRoleCapabilityDefaults, + resolveUserCapabilities, + getAgentSessionPolicy, + getRoleCapabilities, + setRoleCapabilities, + getUserCapabilities, + setUserCapabilities, + clearUserCapabilityOverrides, + resolveUserPolicies, + getRolePolicies, + setRolePolicies, + getUserPolicies, + setUserPolicies, + clearUserPolicyOverrides, + getRoleSkills, + setRoleSkills, + getUserSkills, + setUserSkills, + clearUserSkillOverrides, + syncUserSkillsForUser, + capabilityCatalog: CAPABILITY_CATALOG, + policyCatalog: POLICY_CATALOG, + skillCatalog, + publicUser, + getUserById + }; +} +function buildUserSessionCookie(token, secure, { domain, maxAge }) { + const parts = [ + `${USER_COOKIE}=${encodeURIComponent(token)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${maxAge}` + ]; + if (domain) parts.push(`Domain=${domain}`); + if (secure) parts.push("Secure"); + return parts.join("; "); +} +function userSessionCookie(token, secure, domain = resolveCookieDomain()) { + return buildUserSessionCookie(token, secure, { + domain, + maxAge: 7 * 24 * 60 * 60 + }); +} +function clearUserSessionCookie(secure, domain = resolveCookieDomain()) { + return buildUserSessionCookie("", secure, { domain, maxAge: 0 }); +} +function userLoginCookies(token, secure, domain = resolveCookieDomain()) { + const cookies = [userSessionCookie(token, secure, domain)]; + if (domain) { + cookies.push(clearUserSessionCookie(secure, null)); + } + return cookies; +} +function resolveCookieDomain() { + const explicit = String(process.env.H5_COOKIE_DOMAIN ?? "").trim(); + if (explicit) return explicit; + try { + const base = String(process.env.H5_PUBLIC_BASE_URL ?? "").trim(); + if (!base) return null; + const hostname = new URL(base).hostname.toLowerCase(); + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + return ".localhost"; + } + if (hostname === "tkmind.cn" || hostname.endsWith(".tkmind.cn")) { + return ".tkmind.cn"; + } + } catch { + } + return null; +} +function resolveCookieDomainForRequest(req) { + const explicit = String(process.env.H5_COOKIE_DOMAIN ?? "").trim(); + if (explicit) return explicit; + const hostCandidates = [ + req?.get?.("x-forwarded-host"), + req?.get?.("host"), + req?.hostname + ]; + const origin = req?.get?.("origin"); + if (origin) { + try { + hostCandidates.push(new URL(origin).host); + } catch { + } + } + for (const raw of hostCandidates) { + const hostname2 = String(raw ?? "").split(":")[0].toLowerCase(); + if (hostname2.endsWith(".localhost")) { + return ".localhost"; + } + if (hostname2 === "localhost" || hostname2 === "127.0.0.1" || hostname2 === "::1" || net.isIP(hostname2)) { + return null; + } + } + const hostname = String(req?.get?.("host") ?? req?.hostname ?? "").split(":")[0].toLowerCase(); + if (!hostname || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || net.isIP(hostname)) { + return null; + } + return resolveCookieDomain(); +} + +// wiki-auth.mjs +import crypto5 from "node:crypto"; +import fs8 from "node:fs"; +import path9 from "node:path"; +var DB_DIR = ""; +var USERS_FILE = ""; +var PAGES_DIR = ""; +function initPaths(dataDir) { + DB_DIR = dataDir; + USERS_FILE = path9.join(DB_DIR, "users.json"); + PAGES_DIR = path9.join(DB_DIR, "pages"); +} +function ensureDb() { + fs8.mkdirSync(DB_DIR, { recursive: true }); + fs8.mkdirSync(PAGES_DIR, { recursive: true }); + if (!fs8.existsSync(USERS_FILE)) { + fs8.writeFileSync(USERS_FILE, JSON.stringify({ users: [] }, null, 2), "utf-8"); + } +} +function readUsers() { + ensureDb(); + return JSON.parse(fs8.readFileSync(USERS_FILE, "utf-8")); +} +function writeUsers(data) { + ensureDb(); + fs8.writeFileSync(USERS_FILE, JSON.stringify(data, null, 2), "utf-8"); +} +function safeEqual3(a, b) { + const ba = Buffer.from(a); + const bb = Buffer.from(b); + return ba.length === bb.length && crypto5.timingSafeEqual(ba, bb); +} +function createWikiAuth(dataDir) { + initPaths(dataDir); + ensureDb(); + const sessions = /* @__PURE__ */ new Map(); + const COOKIE_NAME = "wiki_session"; + function hashPassword2(password, salt) { + return crypto5.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); + } + function register(username, password, displayName) { + const db = readUsers(); + if (db.users.find((u) => u.username === username)) { + return { ok: false, message: "\u7528\u6237\u540D\u5DF2\u5B58\u5728" }; + } + const salt = crypto5.randomBytes(16).toString("hex"); + const hashed = hashPassword2(password, salt); + const user = { + id: crypto5.randomUUID(), + username, + displayName: displayName || username, + salt, + hashedPassword: hashed, + createdAt: Date.now() + }; + db.users.push(user); + writeUsers(db); + return { ok: true, user: { id: user.id, username: user.username, displayName: user.displayName } }; + } + function login(username, password) { + const db = readUsers(); + const user = db.users.find((u) => u.username === username); + if (!user) return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" }; + const hashed = hashPassword2(password, user.salt); + if (!safeEqual3(hashed, user.hashedPassword)) { + return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" }; + } + const token = crypto5.randomBytes(32).toString("base64url"); + sessions.set(token, { userId: user.id, username: user.username, expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1e3 }); + return { ok: true, token, user: { id: user.id, username: user.username, displayName: user.displayName } }; + } + function verify(token) { + if (!token) return null; + const session = sessions.get(token); + if (!session || session.expiresAt < Date.now()) { + sessions.delete(token); + return null; + } + session.expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1e3; + return session; + } + function revoke(token) { + sessions.delete(token); + } + function getUser(username) { + const db = readUsers(); + const user = db.users.find((u) => u.username === username); + if (!user) return null; + return { id: user.id, username: user.username, displayName: user.displayName, createdAt: user.createdAt }; + } + function getAllUsers() { + const db = readUsers(); + return db.users.map((u) => ({ id: u.id, username: u.username, displayName: u.displayName, createdAt: u.createdAt })); + } + function listPages(username) { + ensureDb(); + const userPagesDir = path9.join(PAGES_DIR, username); + if (!fs8.existsSync(userPagesDir)) return []; + return fs8.readdirSync(userPagesDir).filter((f) => f.endsWith(".json")).map((f) => { + const data = JSON.parse(fs8.readFileSync(path9.join(userPagesDir, f), "utf-8")); + return { + id: data.id, + title: data.title, + slug: data.slug, + updatedAt: data.updatedAt, + createdAt: data.createdAt, + tags: data.tags || [] + }; + }).sort((a, b) => b.updatedAt - a.updatedAt); + } + function getPage(username, slug) { + ensureDb(); + const filePath = path9.join(PAGES_DIR, username, `${slug}.json`); + if (!fs8.existsSync(filePath)) return null; + return JSON.parse(fs8.readFileSync(filePath, "utf-8")); + } + function savePage(username, slug, title, content, tags) { + ensureDb(); + const userPagesDir = path9.join(PAGES_DIR, username); + fs8.mkdirSync(userPagesDir, { recursive: true }); + const filePath = path9.join(userPagesDir, `${slug}.json`); + const existing = fs8.existsSync(filePath) ? JSON.parse(fs8.readFileSync(filePath, "utf-8")) : null; + const page = { + id: existing?.id || crypto5.randomUUID(), + slug, + title: title || slug, + content: content || "", + tags: tags || [], + username, + createdAt: existing?.createdAt || Date.now(), + updatedAt: Date.now() + }; + fs8.writeFileSync(filePath, JSON.stringify(page, null, 2), "utf-8"); + return page; + } + function deletePage(username, slug) { + ensureDb(); + const filePath = path9.join(PAGES_DIR, username, `${slug}.json`); + if (fs8.existsSync(filePath)) { + fs8.unlinkSync(filePath); + return true; + } + return false; + } + function searchPages(username, query) { + const pages = listPages(username); + const q = query.toLowerCase(); + return pages.filter( + (p) => p.title.toLowerCase().includes(q) || p.tags.some((t) => t.toLowerCase().includes(q)) + ); + } + return { + COOKIE_NAME, + register, + login, + verify, + revoke, + getUser, + getAllUsers, + listPages, + getPage, + savePage, + deletePage, + searchPages + }; +} + +// scripts/local-test-config.mjs +var LOCAL_IP = process.env.LOCAL_TEST_IP ?? "127.0.0.1"; +var DNS_PORT = Number(process.env.LOCAL_TEST_DNS_PORT ?? 5533); +var H5_HOST = process.env.H5_LOCAL_HOST ?? "h5.localhost"; +var ADMIN_HOST = process.env.ADMIN_LOCAL_HOST ?? "adm.localhost"; +var PLAZA_HOST = process.env.PLAZA_LOCAL_HOST ?? "pla.localhost"; +var OPS_HOST = process.env.OPS_LOCAL_HOST ?? "ops.localhost"; +var TEST_HOSTS = [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST]; +var USES_LOCALHOST = TEST_HOSTS.every((host) => host === "localhost" || host.endsWith(".localhost")); +var PUBLIC_SCHEME = (process.env.LOCAL_TEST_SCHEME ?? (String(process.env.LOCAL_TEST_HTTPS ?? "1") === "0" ? "http" : "https")).replace(/:$/, ""); +var PUBLIC_PORT = Number( + process.env.LOCAL_TEST_PUBLIC_PORT ?? (USES_LOCALHOST ? 8443 : PUBLIC_SCHEME === "https" ? 443 : 80) +); +function publicUrl(host, { path: path23 = "" } = {}) { + const defaultPort = PUBLIC_SCHEME === "https" ? 443 : 80; + const portSuffix = PUBLIC_PORT === defaultPort ? "" : `:${PUBLIC_PORT}`; + const normalizedPath = path23.startsWith("/") ? path23 : path23 ? `/${path23}` : ""; + return `${PUBLIC_SCHEME}://${host}${portSuffix}${normalizedPath}`; +} +var H5_PUBLIC_BASE = (process.env.H5_PUBLIC_BASE_URL ?? publicUrl(H5_HOST)).replace(/\/$/, ""); +var ADMIN_PUBLIC_BASE = publicUrl(ADMIN_HOST).replace(/\/$/, ""); +var PLAZA_PUBLIC_BASE = (process.env.PLAZA_PUBLIC_BASE ?? publicUrl(PLAZA_HOST)).replace(/\/$/, ""); +var OPS_PUBLIC_BASE = publicUrl(OPS_HOST, { path: "/ops/" }).replace(/\/$/, "") + "/"; +function isLocalDevHostname(hostname) { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost"); +} + +// mindspace-workspace-thumbnails.mjs +import fs10 from "node:fs"; +import fsPromises from "node:fs/promises"; +import path11 from "node:path"; + +// mindspace-thumbnails.mjs +import fs9 from "node:fs/promises"; +import path10 from "node:path"; +var FEED_WIDTH = 540; +var FEED_HEIGHT = 720; +var MAX_COVER_BYTES = 1.5 * 1024 * 1024; +var REMOTE_COVER_TIMEOUT_MS = 8e3; +function escapeXml(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} +function titleFromHtml(html) { + const match = String(html).match(/]*>([^<]+)<\/title>/i); + return match?.[1]?.trim() ?? ""; +} +function h1FromHtml(html) { + const match = String(html).match(/]*>([\s\S]*?)<\/h1>/i); + if (!match) return ""; + return match[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); +} +function descriptionFromHtml(html) { + const meta = String(html).match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ?? String(html).match(/]+content=["']([^"']+)["'][^>]+name=["']description["']/i); + if (meta?.[1]) return meta[1].trim(); + const paragraph = String(html).match(/]*>([^<]{4,120})/i); + return paragraph?.[1]?.trim() ?? ""; +} +function parseCoverMeta(html) { + const tag = String(html).match(/]*name=["']mindspace-cover["'][^>]*>/i)?.[0]; + if (!tag) return {}; + const contentMatch = tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i); + const raw = contentMatch?.[2] ?? contentMatch?.[1]; + if (!raw) return {}; + try { + return JSON.parse(raw.replaceAll(""", '"')); + } catch { + return {}; + } +} +function extractEmoji(text) { + const match = String(text).match(new RegExp("\\p{Extended_Pictographic}", "u")); + return match?.[0] ?? ""; +} +function coverImageFromHtml(html) { + const source = String(html); + const og = source.match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ?? source.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i); + if (og?.[1]) return og[1].trim(); + const hero = source.match(/]+class=["'][^"']*hero[^"']*["'][^>]+src=["']([^"']+)["']/i) ?? source.match(/]+src=["']([^"']+)["'][^>]+class=["'][^"']*hero/i); + if (hero?.[1]) return hero[1].trim(); + const first = source.match(/]+src=["']([^"']+)["']/i); + return first?.[1]?.trim() ?? ""; +} +function svgImageHref(value) { + const raw = String(value); + if (raw.startsWith("data:")) return raw; + return escapeXml(raw); +} +function mimeFromImageBytes(buffer) { + if (buffer[0] === 255 && buffer[1] === 216) return "image/jpeg"; + if (buffer[0] === 137 && buffer[1] === 80) return "image/png"; + if (buffer.length >= 12 && buffer.slice(0, 4).toString() === "RIFF" && buffer.slice(8, 12).toString() === "WEBP") { + return "image/webp"; + } + if (buffer[0] === 71 && buffer[1] === 73) return "image/gif"; + return "image/jpeg"; +} +function bytesToDataUri(buffer, mimeType) { + return `data:${mimeType};base64,${buffer.toString("base64")}`; +} +function bufferToImageDataUri(buffer) { + if (!buffer?.length) return null; + if (buffer.length > MAX_COVER_BYTES) return null; + return bytesToDataUri(buffer, mimeFromImageBytes(buffer)); +} +async function readLocalImageDataUri(filePath) { + const buffer = await fs9.readFile(filePath); + if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null; + return bytesToDataUri(buffer, mimeFromImageBytes(buffer)); +} +async function fetchRemoteImageDataUri(url) { + const response = await fetch(url, { + signal: AbortSignal.timeout(REMOTE_COVER_TIMEOUT_MS), + headers: { Accept: "image/*" }, + redirect: "follow" + }); + if (!response.ok) return null; + const contentType = response.headers.get("content-type") ?? ""; + if (contentType && !contentType.startsWith("image/")) return null; + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null; + const mimeType = contentType.startsWith("image/") ? contentType.split(";")[0] : mimeFromImageBytes(buffer); + return bytesToDataUri(buffer, mimeType); +} +function resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) { + if (!imageUrl || /^data:|^https?:/i.test(imageUrl)) return null; + let baseDir = null; + if (contentStorageKey) { + baseDir = path10.dirname(path10.resolve(storageRoot, contentStorageKey)); + const root = path10.resolve(storageRoot); + if (baseDir !== root && !baseDir.startsWith(`${root}${path10.sep}`)) return null; + } else if (contentBaseDir) { + baseDir = path10.resolve(contentBaseDir); + } + if (!baseDir) return null; + const target = path10.resolve(baseDir, imageUrl.replace(/^\.\//, "")); + if (target !== baseDir && !target.startsWith(`${baseDir}${path10.sep}`)) return null; + return target; +} +async function resolveCoverDataUri({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) { + const raw = String(imageUrl ?? "").trim(); + if (!raw) return null; + if (raw.startsWith("data:")) return raw.length <= MAX_COVER_BYTES * 2 ? raw : null; + if (/^https?:\/\//i.test(raw)) { + try { + return await fetchRemoteImageDataUri(raw); + } catch { + return null; + } + } + const localPath = resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl: raw }); + if (!localPath) return null; + try { + return await readLocalImageDataUri(localPath); + } catch { + return null; + } +} +function colorsFromHtml(html) { + const colors = []; + const hexRe = /#(?:[0-9a-f]{3}){1,2}\b/gi; + let match; + while ((match = hexRe.exec(String(html))) !== null && colors.length < 6) { + const normalized = normalizeHex(match[0]); + if (!normalized) continue; + if (["#ffffff", "#fff", "#000000", "#000"].includes(normalized)) continue; + if (!colors.includes(normalized)) colors.push(normalized); + } + return colors; +} +function normalizeHex(value) { + const raw = String(value).trim().toLowerCase(); + if (!raw.startsWith("#")) return null; + if (raw.length === 4) { + return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`; + } + if (raw.length === 7) return raw; + return null; +} +function darken(hex, amount = 0.28) { + const color = normalizeHex(hex) ?? "#2f6f57"; + const r = Math.round(parseInt(color.slice(1, 3), 16) * (1 - amount)); + const g = Math.round(parseInt(color.slice(3, 5), 16) * (1 - amount)); + const b = Math.round(parseInt(color.slice(5, 7), 16) * (1 - amount)); + return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`; +} +function splitTitleLines(title, maxLines = 2) { + const cleaned = String(title || "\u672A\u547D\u540D\u9875\u9762").replace(/\s+/g, " ").trim(); + if (!cleaned) return ["\u672A\u547D\u540D\u9875\u9762"]; + if (cleaned.includes("|")) { + return cleaned.split("|").map((part) => part.trim()).filter(Boolean).slice(0, maxLines); + } + if (cleaned.length <= 16) return [cleaned]; + const midpoint = Math.ceil(cleaned.length / 2); + const splitAt = cleaned.lastIndexOf(" ", midpoint) > 8 ? cleaned.lastIndexOf(" ", midpoint) : midpoint; + return [cleaned.slice(0, splitAt).trim(), cleaned.slice(splitAt).trim()].filter(Boolean); +} +function lighten(hex, amount = 0.22) { + const color = normalizeHex(hex) ?? "#2f6f57"; + const r = Math.min(255, Math.round(parseInt(color.slice(1, 3), 16) + 255 * amount)); + const g = Math.min(255, Math.round(parseInt(color.slice(3, 5), 16) + 255 * amount)); + const b = Math.min(255, Math.round(parseInt(color.slice(5, 7), 16) + 255 * amount)); + return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`; +} +function inferTagFromContent(html, title = "") { + const text = `${title} +${String(html).slice(0, 8e3)}`; + if (/旅行|旅游|攻略|travel/i.test(text)) return "\u65C5\u884C"; + if (/美食|餐厅|菜谱|料理|food/i.test(text)) return "\u7F8E\u98DF"; + if (/报告|分析|研报|数据|report/i.test(text)) return "\u62A5\u544A"; + if (/普拉提|瑜伽|健身|运动|pilates|yoga/i.test(text)) return "\u8FD0\u52A8"; + if (/618|促销|活动|优惠|限时|大促/i.test(text)) return "\u6D3B\u52A8"; + return null; +} +function shouldUseScenicBackground(signals) { + return /旅行|travel|美食|food|餐|报告|report|分析/i.test(String(signals.tag ?? "")); +} +function resolvePhotoPalette(signals) { + const tag = String(signals.tag ?? ""); + const accent = signals.accent; + const accent2 = signals.accent2; + if (/旅行|travel/i.test(tag)) { + return { + sky: "#4f8fb8", + glow: "#f6d7a8", + horizon: "#e39a4d", + land: "#24343a", + shadow: "#0d1518", + flare: "#ffe9c7", + bokeh: "#fff8ef" + }; + } + if (/美食|food|餐/i.test(tag)) { + return { + sky: "#5a2b22", + glow: "#ffb27a", + horizon: "#d85f3b", + land: "#241412", + shadow: "#120909", + flare: "#ffd0a8", + bokeh: "#ffe8d6" + }; + } + if (/报告|report|分析/i.test(tag)) { + return { + sky: "#3d4f68", + glow: "#9eb4d8", + horizon: "#607892", + land: "#1a2430", + shadow: "#0a1018", + flare: "#c8d8ef", + bokeh: "#eef3fb" + }; + } + return { + sky: lighten(accent, 0.18), + glow: lighten(accent, 0.34), + horizon: accent, + land: darken(accent2, 0.1), + shadow: darken(accent2, 0.32), + flare: lighten(accent, 0.42), + bokeh: "#fff8f2" + }; +} +function extractCoverSignals(html, meta = {}) { + const coverMeta = parseCoverMeta(html); + const rawTitle = meta.title || h1FromHtml(html) || titleFromHtml(html) || "\u672A\u547D\u540D\u9875\u9762"; + const title = rawTitle.replace(new RegExp("\\p{Extended_Pictographic}", "gu"), "").replace(/\s+/g, " ").trim(); + const colors = colorsFromHtml(html); + const accent = normalizeHex(coverMeta.accent ?? meta.accent ?? colors[0] ?? "#2f6f57"); + const accent2 = normalizeHex(coverMeta.accent2 ?? colors[1] ?? darken(accent, 0.15)); + return { + title: title || "\u672A\u547D\u540D\u9875\u9762", + subtitle: coverMeta.subtitle ?? meta.subtitle ?? descriptionFromHtml(html) ?? "TKMind \u4F5C\u54C1", + tag: coverMeta.tag ?? meta.tag ?? inferTagFromContent(html, title) ?? "\u7CBE\u9009\u9875\u9762", + emoji: coverMeta.emoji ?? meta.emoji ?? extractEmoji(rawTitle) ?? extractEmoji(h1FromHtml(html)), + accent, + accent2, + mood: coverMeta.mood ?? meta.mood ?? "photo", + image: coverMeta.cover ?? coverMeta.image ?? meta.cover ?? meta.image ?? coverImageFromHtml(html) + }; +} +function themeBackgroundLayers(signals) { + const accent = escapeXml(signals.accent); + const accent2 = escapeXml(signals.accent2); + const glow = escapeXml(lighten(signals.accent, 0.28)); + return ` + + `; +} +function buildFeedThumbnailSvg(signals, options = {}) { + const coverDataUri = options.coverDataUri ?? null; + const hasPhoto = Boolean(coverDataUri); + const useScenic = !hasPhoto && shouldUseScenicBackground(signals); + const palette = resolvePhotoPalette(signals); + const titleLines = splitTitleLines(signals.title); + const line1 = escapeXml(titleLines[0] ?? signals.title).slice(0, 24); + const line2 = escapeXml(titleLines[1] ?? "").slice(0, 24); + const subtitle = escapeXml(signals.subtitle).slice(0, 42); + const tag = escapeXml(signals.tag).slice(0, 12); + const sky = escapeXml(palette.sky); + const glow = escapeXml(palette.glow); + const horizon = escapeXml(palette.horizon); + const land = escapeXml(palette.land); + const shadow = escapeXml(palette.shadow); + const flare = escapeXml(palette.flare); + const bokeh = escapeXml(palette.bokeh); + const titleY2 = line2 ? 652 : 0; + const scenicLayers = useScenic ? ` + + + + + + + ` : !hasPhoto ? themeBackgroundLayers(signals) : ""; + const photoLayer = hasPhoto ? `` : ""; + const overlayStops = hasPhoto ? ` + + ` : ` + + `; + const vignetteOpacity = hasPhoto ? "0.52" : "0.42"; + const grainOpacity = hasPhoto ? "0.18" : "0.28"; + return ` + + + + + + + + + + + + + + + + + + + + + + + ${overlayStops} + + + + + + + + + + + + + + + + + + + + + + ${photoLayer} + ${scenicLayers} + + + + ${tag.toUpperCase()} + ${line1} + ${line2 ? `${line2}` : ""} + ${subtitle} + TKMIND +`; +} +function assetThumbnailKey(userId, assetId) { + return path10.posix.join("users", userId, "assets", assetId, "thumbnail.svg"); +} +function pageThumbnailKey(userId, pageId) { + return path10.posix.join("users", userId, "pages", pageId, "thumbnail.svg"); +} +async function writeThumbnail(storageRoot, storageKey, svg) { + const target = path10.resolve(storageRoot, storageKey); + const root = path10.resolve(storageRoot); + if (target !== root && !target.startsWith(`${root}${path10.sep}`)) { + throw new Error("\u7F29\u7565\u56FE\u8DEF\u5F84\u8D8A\u754C"); + } + await fs9.mkdir(path10.dirname(target), { recursive: true }); + await fs9.writeFile(target, svg, "utf8"); + return target; +} +function isModernFeedThumbnail(svg) { + if (!svg) return false; + return /width="540" height="720"/.test(svg) && /filter id="grain"/.test(svg); +} +async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { + const signals = extractCoverSignals(html, meta); + const coverDataUri = await resolveCoverDataUri({ + storageRoot, + contentStorageKey: meta.contentStorageKey, + contentBaseDir: meta.contentBaseDir, + imageUrl: signals.image + }); + const svg = buildFeedThumbnailSvg(signals, { coverDataUri }); + await writeThumbnail(storageRoot, storageKey, svg); + return svg; +} +async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { + const existing = await readThumbnailIfExists(storageRoot, storageKey); + if (existing && isModernFeedThumbnail(existing) && !meta.force) { + return existing; + } + return generateHtmlThumbnail(storageRoot, storageKey, html, meta); +} +async function ensurePageThumbnail({ + storageRoot, + pageThumbnailStorageKey, + html, + meta = {}, + workspacePublishDir = null, + workspaceHtmlRelativePath = null +}) { + if (workspacePublishDir && workspaceHtmlRelativePath) { + const sidecar = await readThumbnailIfExists( + workspacePublishDir, + workspaceThumbnailRelativePath(workspaceHtmlRelativePath) + ); + if (sidecar && isModernFeedThumbnail(sidecar)) { + await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar); + return sidecar; + } + } + return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, meta); +} +async function readThumbnailIfExists(storageRoot, storageKey) { + try { + return await fs9.readFile(path10.resolve(storageRoot, storageKey), "utf8"); + } catch { + return null; + } +} +function scheduleHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { + queueMicrotask(() => { + void ensureHtmlThumbnail(storageRoot, storageKey, html, meta).catch(() => { + }); + }); +} + +// mindspace-workspace-thumbnails.mjs +function workspaceThumbnailRelativePath(htmlRelativePath) { + const normalized = String(htmlRelativePath ?? "").replace(/^\/+/, ""); + const dir = path11.posix.dirname(normalized); + const base = path11.basename(normalized, path11.extname(normalized)); + const thumb = `${base}.thumbnail.svg`; + return dir === "." ? thumb : path11.posix.join(dir, thumb); +} +async function ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath, html, meta = {}) { + const htmlPath = path11.join(publishDir, htmlRelativePath); + const content = html ?? await fsPromises.readFile(htmlPath, "utf8"); + const thumbRel = workspaceThumbnailRelativePath(htmlRelativePath); + return ensureHtmlThumbnail(publishDir, thumbRel, content, { + ...meta, + contentBaseDir: path11.dirname(htmlPath) + }); +} +async function scanPublishTree(publishRoot) { + if (!fs10.existsSync(publishRoot)) return; + const entries = await fsPromises.readdir(publishRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === "wiki" || entry.name.startsWith(".")) continue; + const userDir = path11.join(publishRoot, entry.name); + await scanUserHtmlFiles(userDir); + } +} +async function scanUserHtmlFiles(userDir) { + const walk = async (dir) => { + const entries = await fsPromises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path11.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === ".agents" || entry.name === "node_modules") continue; + await walk(full); + continue; + } + if (!entry.name.endsWith(".html") || entry.name.endsWith(".thumbnail.svg")) continue; + const rel = path11.relative(userDir, full); + await ensureWorkspaceHtmlThumbnail(userDir, rel).catch(() => { + }); + } + }; + await walk(userDir); +} +function startWorkspaceThumbnailWatcher(publishRoot) { + if (!fs10.existsSync(publishRoot)) { + fs10.mkdirSync(publishRoot, { recursive: true }); + } + void scanPublishTree(publishRoot); + const pending = /* @__PURE__ */ new Map(); + const schedule = (userDir, relativePath) => { + const key = path11.join(userDir, relativePath); + const existing = pending.get(key); + if (existing) clearTimeout(existing); + pending.set( + key, + setTimeout(() => { + pending.delete(key); + void ensureWorkspaceHtmlThumbnail(userDir, relativePath).catch(() => { + }); + }, 400) + ); + }; + const attachUserWatcher = (userDir) => { + if (!fs10.existsSync(userDir)) return; + void scanUserHtmlFiles(userDir); + try { + fs10.watch(userDir, { recursive: true }, (_event, filename) => { + if (!filename || !String(filename).endsWith(".html")) return; + if (String(filename).endsWith(".thumbnail.svg")) return; + schedule(userDir, filename); + }); + } catch { + } + }; + for (const entry of fs10.readdirSync(publishRoot, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== "wiki" && !entry.name.startsWith(".")) { + attachUserWatcher(path11.join(publishRoot, entry.name)); + } + } + try { + fs10.watch(publishRoot, (_event, filename) => { + if (!filename) return; + const userDir = path11.join(publishRoot, filename); + if (fs10.existsSync(userDir) && fs10.statSync(userDir).isDirectory()) { + attachUserWatcher(userDir); + } + }); + } catch { + } +} + +// mindspace-workspace-sync.mjs +import crypto7 from "node:crypto"; +import fs12 from "node:fs"; +import fsPromises2 from "node:fs/promises"; +import path13 from "node:path"; + +// mindspace-assets.mjs +import crypto6 from "node:crypto"; +import fs11 from "node:fs/promises"; +import path12 from "node:path"; + +// mindspace-scan.mjs +var SCRIPT_PATTERNS = [ + / buffer.length) break; + if (compressionMethod !== 0 && compressedSize > 0 && uncompressedSize / compressedSize > MAX_ZIP_COMPRESSION_RATIO) { + return { + scanStatus: "blocked", + riskLevel: "critical", + findings: ["zip_bomb_ratio"] + }; + } + headers += 1; + offset += headerSize; + if (compressedSize > 0) { + offset += compressedSize; + } + } + return null; +} +function runBasicFileScan(buffer, { filename, mimeType }) { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + return { + scanStatus: "blocked", + riskLevel: "high", + findings: ["empty_file"] + }; + } + if (mimeType.startsWith("text/") || mimeType === "application/pdf") { + const textFinding = scanTextContent(buffer); + if (textFinding) return textFinding; + } + if (mimeType === "application/pdf" && buffer.subarray(0, 5).toString("ascii") !== "%PDF-") { + return { + scanStatus: "blocked", + riskLevel: "medium", + findings: ["pdf_signature_mismatch"] + }; + } + if (isZipContainer(mimeType)) { + const zipFinding = scanZipStructure(buffer); + if (zipFinding) return zipFinding; + } + return { + scanStatus: "passed", + riskLevel: "none", + findings: [] + }; +} + +// mindspace-attachment-text.mjs +import zlib from "node:zlib"; +function escapeXml2(text) { + return String(text ?? "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'"); +} +function extractZipEntry(buffer, targetName) { + let offset = 0; + while (offset + 30 <= buffer.length) { + if (buffer.subarray(offset, offset + 2).toString("ascii") !== "PK") break; + const compressionMethod = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const name = buffer.subarray(offset + 30, offset + 30 + nameLength).toString("utf8"); + const dataStart = offset + 30 + nameLength + extraLength; + if (name === targetName) { + const compressed = buffer.subarray(dataStart, dataStart + compressedSize); + if (compressionMethod === 0) return compressed; + if (compressionMethod === 8) return zlib.inflateRawSync(compressed); + return null; + } + offset = dataStart + compressedSize; + } + return null; +} +function extractXmlText(xml) { + const paragraphs = []; + for (const block of String(xml ?? "").split("")) { + const texts = [...block.matchAll(/]*>([\s\S]*?)<\/w:t>/g)].map( + (match) => escapeXml2(match[1]) + ); + const line = texts.join("").replace(/\s+/g, " ").trim(); + if (line) paragraphs.push(line); + } + return paragraphs.join("\n\n"); +} +function extractDocxText(buffer) { + const xmlBuffer = extractZipEntry(buffer, "word/document.xml"); + return xmlBuffer ? extractXmlText(xmlBuffer.toString("utf8")) : ""; +} +function extractXlsxText(buffer) { + const sharedStringsXml = extractZipEntry(buffer, "xl/sharedStrings.xml"); + const sharedStrings = sharedStringsXml ? [...sharedStringsXml.toString("utf8").matchAll(/]*>([\s\S]*?)<\/t>/g)].map( + (match) => escapeXml2(match[1]) + ) : []; + const sheetEntries = []; + let index = 1; + while (true) { + const sheet = extractZipEntry(buffer, `xl/worksheets/sheet${index}.xml`); + if (!sheet) break; + const rows = []; + for (const rowBlock of sheet.toString("utf8").split("")) { + const cells = []; + for (const cell of rowBlock.matchAll(/]*?(?:t="([^"]+)")?[^>]*>(?:[\s\S]*?([\s\S]*?)<\/v>)?/g)) { + const type = cell[1] ?? ""; + const value = cell[2] ?? ""; + if (type === "s") { + const sharedIndex = Number(value); + cells.push(sharedStrings[sharedIndex] ?? value); + } else { + cells.push(escapeXml2(value)); + } + } + if (cells.length) rows.push(cells.join(" ")); + } + if (rows.length) { + sheetEntries.push(`Sheet ${index} +${rows.join("\n")}`); + } + index += 1; + } + return sheetEntries.join("\n\n"); +} +function extractPptxText(buffer) { + const slides = []; + let index = 1; + while (true) { + const slide = extractZipEntry(buffer, `ppt/slides/slide${index}.xml`); + if (!slide) break; + const texts = [...slide.toString("utf8").matchAll(/]*>([\s\S]*?)<\/a:t>/g)].map( + (match) => escapeXml2(match[1]) + ); + const text = texts.join(" ").replace(/\s+/g, " ").trim(); + if (text) slides.push(`Slide ${index} +${text}`); + index += 1; + } + return slides.join("\n\n"); +} +function extractPdfText(buffer) { + const text = buffer.toString("latin1"); + const segments = []; + for (const streamMatch of text.matchAll(/stream\r?\n([\s\S]*?)\r?\nendstream/g)) { + const stream = streamMatch[1]; + for (const textMatch of stream.matchAll(/\(([^()\\]*(?:\\.[^()\\]*)*)\)\s*Tj/g)) { + const raw = textMatch[1].replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\\\/g, "\\").replace(/\\\(/g, "(").replace(/\\\)/g, ")"); + const cleaned = raw.replace(/\s+/g, " ").trim(); + if (cleaned) segments.push(cleaned); + } + } + return segments.join("\n"); +} +function extractAttachmentText(buffer, mimeType, filename = "") { + const normalizedMimeType = String(mimeType ?? ""); + const extension = String(filename ?? "").toLowerCase(); + if (normalizedMimeType === "application/pdf") { + const text = extractPdfText(buffer); + return { text, format: "pdf", warnings: text ? [] : ["pdf_text_not_detected"] }; + } + if (normalizedMimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || extension.endsWith(".docx")) { + const text = extractDocxText(buffer); + return { text, format: "docx", warnings: text ? [] : ["docx_text_not_detected"] }; + } + if (normalizedMimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || extension.endsWith(".xlsx")) { + const text = extractXlsxText(buffer); + return { text, format: "xlsx", warnings: text ? [] : ["xlsx_text_not_detected"] }; + } + if (normalizedMimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" || extension.endsWith(".pptx")) { + const text = extractPptxText(buffer); + return { text, format: "pptx", warnings: text ? [] : ["pptx_text_not_detected"] }; + } + if (normalizedMimeType.startsWith("text/")) { + return { text: buffer.toString("utf8"), format: "text", warnings: [] }; + } + return { text: "", format: "unsupported", warnings: ["unsupported_attachment_type"] }; +} + +// mindspace-asset-preview.mjs +import zlib2 from "node:zlib"; +var PREVIEWABLE_MIME_TYPES = /* @__PURE__ */ new Set([ + "text/html", + "text/plain", + "text/markdown", + "text/csv", + "application/pdf", + "image/png", + "image/jpeg", + "image/webp", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +]); +var PREVIEW_SHELL_STYLE = ` +html,body{margin:0;padding:0;background:#f5f0e5;color:#1f2937;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +body{padding:24px;box-sizing:border-box;line-height:1.6} +pre,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +pre{white-space:pre-wrap;word-break:break-word;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px} +table{border-collapse:collapse;width:100%;background:#fff;border-radius:12px;overflow:hidden} +th,td{border:1px solid #e5e7eb;padding:8px 10px;text-align:left;font-size:14px} +th{background:#faf7ef} +.docx-preview h1{font-size:1.5rem;margin:0 0 16px} +.docx-preview p{margin:0 0 12px;text-indent:2em} +.docx-preview .meta{color:#6b7280;font-size:13px;margin-bottom:20px;text-indent:0} +.pdf-frame,.image-frame{display:block;width:100%;min-height:calc(100vh - 48px);border:0;border-radius:12px;background:#fff} +.image-frame{object-fit:contain;max-height:calc(100vh - 48px);width:auto;max-width:100%;margin:0 auto;cursor:zoom-in} +.image-viewer{padding:0} +.image-viewer h1,.image-viewer .meta{display:none} +.image-viewer .image-frame{min-height:100vh;max-height:100vh;margin:0;border-radius:0;background:#0b100e} +.image-lightbox[hidden]{display:none} +.image-lightbox{position:fixed;inset:0;z-index:9999;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));background:rgba(7,12,10,.9);cursor:zoom-out} +.image-lightbox img{display:block;max-width:min(100%,1600px);max-height:calc(100vh - 32px);object-fit:contain;border-radius:8px;box-shadow:0 24px 80px rgba(0,0,0,.35);cursor:default} +.image-lightbox-close{position:fixed;top:max(16px,env(safe-area-inset-top));right:max(16px,env(safe-area-inset-right));z-index:10000;display:grid;place-items:center;width:40px;height:40px;padding:0;border:0;border-radius:999px;color:#fffaf0;background:rgba(24,33,29,.72);box-shadow:0 8px 24px rgba(0,0,0,.28);cursor:pointer} +.image-lightbox-close svg{display:block;width:18px;height:18px;stroke:currentColor;stroke-width:2;fill:none} +.image-lightbox-close:hover{background:rgba(24,33,29,.92)} +`; +var IMAGE_LIGHTBOX_CLOSE_ICON = ''; +var IMAGE_LIGHTBOX_SCRIPT = ``; +function escapeHtml(text) { + return String(text ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function previewDocument(title, bodyHtml, { downloadUrl = null, extraHead = "", allowScripts = false, bodyClass = "" } = {}) { + const csp = [ + "default-src 'none'", + "style-src 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "frame-src 'self'", + "object-src 'self'", + "base-uri 'none'", + "form-action 'none'", + allowScripts ? "script-src 'unsafe-inline'" : "script-src 'none'" + ].join("; "); + const downloadLink = downloadUrl ? `

\u4E0B\u8F7D\u539F\u6587\u4EF6

` : ""; + const bodyAttrs = bodyClass ? ` class="${escapeHtml(bodyClass)}"` : ""; + return `${escapeHtml(title)}${extraHead}

${escapeHtml(title)}

${downloadLink}${bodyHtml}`; +} +function renderImageLightboxMarkup({ downloadUrl, title, triggerClass = "image-frame" }) { + const safeUrl = escapeHtml(downloadUrl); + const safeTitle = escapeHtml(title); + return `${safeTitle}${IMAGE_LIGHTBOX_SCRIPT}`; +} +function wantsInlineImageViewer(req) { + if (req?.query?.viewer === "1") return true; + if (req?.query?.viewer === "0") return false; + const fetchDest = String(req?.get?.("sec-fetch-dest") ?? req?.headers?.["sec-fetch-dest"] ?? "").toLowerCase(); + if (fetchDest === "image") return false; + if (fetchDest === "document" || fetchDest === "iframe") return true; + const accept = String(req?.get?.("accept") ?? req?.headers?.accept ?? ""); + return /text\/html/i.test(accept); +} +function renderImageAssetViewerHtml({ asset, downloadUrl }) { + const title = asset.displayName || asset.filename; + return previewDocument(title, renderImageLightboxMarkup({ downloadUrl, title }), { + downloadUrl, + allowScripts: true, + bodyClass: "image-viewer" + }); +} +function renderInlineMarkdown(text) { + let html = escapeHtml(text); + html = html.replace(/`([^`]+)`/g, "$1"); + html = html.replace(/\*\*(.+?)\*\*/g, "$1"); + html = html.replace(/\*(.+?)\*/g, "$1"); + html = html.replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1'); + return html; +} +function renderMarkdownDocument(text) { + const lines = String(text ?? "").split("\n"); + const blocks = []; + let inCode = false; + let code = []; + for (const line of lines) { + if (line.startsWith("```")) { + if (inCode) { + blocks.push(`
${escapeHtml(code.join("\n"))}
`); + code = []; + inCode = false; + } else { + inCode = true; + } + continue; + } + if (inCode) { + code.push(line); + continue; + } + if (!line.trim()) continue; + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + const level = heading[1].length; + blocks.push(`${renderInlineMarkdown(heading[2])}`); + continue; + } + blocks.push(`

${renderInlineMarkdown(line)}

`); + } + if (inCode && code.length) { + blocks.push(`
${escapeHtml(code.join("\n"))}
`); + } + return blocks.join("\n"); +} +function parseCsvRows(text) { + const rows = []; + let row = []; + let cell = ""; + let inQuotes = false; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + const next = text[i + 1]; + if (inQuotes) { + if (ch === '"' && next === '"') { + cell += '"'; + i += 1; + } else if (ch === '"') { + inQuotes = false; + } else { + cell += ch; + } + continue; + } + if (ch === '"') { + inQuotes = true; + continue; + } + if (ch === ",") { + row.push(cell); + cell = ""; + continue; + } + if (ch === "\n") { + row.push(cell); + rows.push(row); + row = []; + cell = ""; + continue; + } + if (ch === "\r") continue; + cell += ch; + } + row.push(cell); + rows.push(row); + return rows.filter((item) => item.some((value) => String(value ?? "").trim())); +} +function renderCsvPreview(text) { + const rows = parseCsvRows(String(text ?? "")); + if (rows.length === 0) return "
\uFF08\u7A7A\u6587\u4EF6\uFF09
"; + const [head, ...body] = rows; + const header = `${head.map((cell) => `${escapeHtml(cell)}`).join("")}`; + const content = body.slice(0, 200).map((line) => `${line.map((cell) => `${escapeHtml(cell)}`).join("")}`).join(""); + const tail = body.length > 200 ? `

\u4EC5\u5C55\u793A\u524D 200 \u884C

` : ""; + return `
${tail}${header}${content}
`; +} +function extractZipEntry2(buffer, targetName) { + let offset = 0; + while (offset + 30 <= buffer.length) { + if (buffer.subarray(offset, offset + 2).toString("ascii") !== "PK") break; + const compressionMethod = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const name = buffer.subarray(offset + 30, offset + 30 + nameLength).toString("utf8"); + const dataStart = offset + 30 + nameLength + extraLength; + if (name === targetName) { + const compressed = buffer.subarray(dataStart, dataStart + compressedSize); + if (compressionMethod === 0) return compressed; + if (compressionMethod === 8) return zlib2.inflateRawSync(compressed); + return null; + } + offset = dataStart + compressedSize; + } + return null; +} +function extractDocxText2(buffer) { + const xmlBuffer = extractZipEntry2(buffer, "word/document.xml"); + if (!xmlBuffer) return ""; + const xml = xmlBuffer.toString("utf8"); + const paragraphs = []; + for (const block of xml.split("")) { + const texts = [...block.matchAll(/]*>([\s\S]*?)<\/w:t>/g)].map( + (match) => match[1].replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"') + ); + const line = texts.join(""); + if (line.trim()) paragraphs.push(line.trim()); + } + return paragraphs.join("\n\n"); +} +function canPreviewAsset(mimeType) { + return PREVIEWABLE_MIME_TYPES.has(mimeType); +} +function renderAssetPreviewHtml({ asset, buffer, downloadUrl }) { + const title = asset.displayName || asset.filename; + const mimeType = asset.mimeType; + if (mimeType === "text/html") { + const csp = ``; + const html = buffer.toString("utf8"); + if (/]*>/i.test(html)) { + return html.replace(/]*)>/i, `${csp}`); + } + return `${csp}${html}`; + } + if (mimeType === "application/pdf") { + return previewDocument( + title, + ``, + { downloadUrl } + ); + } + if (mimeType.startsWith("image/")) { + return previewDocument(title, renderImageLightboxMarkup({ downloadUrl, title }), { + downloadUrl, + allowScripts: true + }); + } + if (mimeType === "text/csv") { + return previewDocument(title, renderCsvPreview(buffer.toString("utf8")), { downloadUrl }); + } + if (mimeType === "text/markdown") { + return previewDocument(title, renderMarkdownDocument(buffer.toString("utf8")), { downloadUrl }); + } + if (mimeType === "text/plain") { + return previewDocument(title, `
${escapeHtml(buffer.toString("utf8"))}
`, { downloadUrl }); + } + if (mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") { + const text = extractDocxText2(buffer); + const body = text ? `
${text.split(/\n{2,}/).map((paragraph) => `

${escapeHtml(paragraph)}

`).join("")}
` : '

\u65E0\u6CD5\u63D0\u53D6\u6B63\u6587\uFF0C\u8BF7\u4E0B\u8F7D\u539F\u6587\u4EF6\u67E5\u770B\u3002

'; + return previewDocument(title, body, { downloadUrl }); + } + throw Object.assign(new Error("\u8BE5\u8D44\u4EA7\u4E0D\u652F\u6301\u9884\u89C8"), { code: "preview_not_supported" }); +} + +// mindspace-assets.mjs +var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Map([ + [".txt", "text/plain"], + [".md", "text/markdown"], + [".csv", "text/csv"], + [".pdf", "application/pdf"], + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".webp", "image/webp"], + [".doc", "application/msword"], + [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + [".xls", "application/vnd.ms-excel"], + [".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + [".ppt", "application/vnd.ms-powerpoint"], + [".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"], + [".html", "text/html"], + [".htm", "text/html"] +]); +var MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024; +var PUBLIC_TEMP_IMAGE_DIR = ".tmp-images"; +var PUBLIC_IMAGE_EXTENSIONS = /* @__PURE__ */ new Map([ + ["image/png", ".png"], + ["image/jpeg", ".jpg"], + ["image/webp", ".webp"] +]); +function asNumber2(value) { + return Number(value ?? 0); +} +function normalizeFilename(filename) { + const normalized = String(filename ?? "").normalize("NFKC").trim(); + if (!normalized || normalized === "." || normalized === ".." || normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0") || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw Object.assign(new Error("\u6587\u4EF6\u540D\u65E0\u6548"), { code: "invalid_filename" }); + } + return normalized.slice(0, 255); +} +function expectedMimeType(filename) { + return ALLOWED_EXTENSIONS.get(path12.extname(filename).toLowerCase()) ?? null; +} +function detectMimeType(buffer, filename) { + const head = buffer.subarray(0, 256).toString("utf8").trimStart().toLowerCase(); + if (head.startsWith(" maxFileBytes) { + throw Object.assign(new Error("\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); + } + const mimeType = expectedMimeType(normalizedFilename); + if (!mimeType) { + throw Object.assign(new Error("\u6682\u4E0D\u652F\u6301\u8BE5\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); + } + if (mimeType.startsWith("image/") && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) { + throw Object.assign(new Error("\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); + } + return { filename: normalizedFilename, sizeBytes: normalizedSize, expectedMimeType: mimeType }; +} +function createAssetService(pool, options = {}) { + const storageRoot = path12.resolve(options.storageRoot ?? path12.join(process.cwd(), "data", "mindspace")); + const h5Root = options.h5Root ? path12.resolve(options.h5Root) : null; + const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES); + const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1e3); + const idFactory = options.idFactory ?? (() => crypto6.randomUUID()); + const workspaceSync = createWorkspaceAssetSync({ + pool, + storageRoot, + h5Root, + maxFileBytes, + idFactory + }); + const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => { + if (!h5Root) return null; + return mirrorAssetToZone({ + workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }), + categoryCode, + filename, + sourcePath + }); + }; + const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, sourcePath) => { + if (!h5Root) return null; + const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const target = path12.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); + await fs11.mkdir(path12.dirname(target), { recursive: true }); + await fs11.copyFile(sourcePath, target); + return target; + }; + const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => { + if (!h5Root) return; + const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const target = path12.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); + await fs11.rm(target, { force: true }); + }; + const absoluteStoragePath = (storageKey) => { + const resolved = path12.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path12.sep}`)) { + throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); + } + return resolved; + }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? "").replace(/\\/g, "/"); + const withoutMd = normalized.endsWith(".md") ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}` + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs11.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === "ENOENT") { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); + }; + const createUpload = async (userId, input) => { + const validated = validateUploadRequest({ ...input, maxFileBytes }); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [categories] = await conn.query( + `SELECT c.id, c.space_id, c.category_code + FROM h5_space_categories c + WHERE c.id = ? AND c.user_id = ? + LIMIT 1 + FOR UPDATE`, + [input.categoryId, userId] + ); + const category = categories[0]; + if (!category) { + throw Object.assign(new Error("\u5206\u7C7B\u4E0D\u5B58\u5728"), { code: "category_not_found" }); + } + if (!["oa", "private", "public"].includes(category.category_code)) { + throw Object.assign(new Error("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u76F4\u63A5\u4E0A\u4F20"), { + code: "category_not_uploadable" + }); + } + const [spaces] = await conn.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE`, + [category.space_id, userId] + ); + const space = spaces[0]; + if (!space || space.status !== "active") { + throw Object.assign(new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528"), { code: "space_unavailable" }); + } + const available = asNumber2(space.quota_bytes) - asNumber2(space.used_bytes) - asNumber2(space.reserved_bytes); + if (available < validated.sizeBytes) { + throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), { + code: "quota_exceeded", + details: { requiredBytes: validated.sizeBytes, availableBytes: Math.max(0, available) } + }); + } + const uploadId = idFactory(); + const now = Date.now(); + const temporaryStorageKey = path12.posix.join("users", userId, "temp", `${uploadId}.upload`); + await conn.query( + `UPDATE h5_user_spaces + SET reserved_bytes = reserved_bytes + ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [validated.sizeBytes, now, space.id, userId] + ); + await conn.query( + `INSERT INTO h5_upload_sessions + (id, user_id, space_id, category_id, filename, expected_size, declared_mime_type, + reserved_bytes, temporary_storage_key, status, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`, + [ + uploadId, + userId, + space.id, + category.id, + validated.filename, + validated.sizeBytes, + input.declaredMimeType || null, + validated.sizeBytes, + temporaryStorageKey, + now + uploadTtlMs, + now + ] + ); + await conn.commit(); + return { + id: uploadId, + filename: validated.filename, + expectedSize: validated.sizeBytes, + uploadUrl: `/api/mindspace/v1/uploads/${uploadId}/content`, + expiresAt: now + uploadTtlMs, + reservedBytes: validated.sizeBytes + }; + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const writeUploadContent = async (userId, uploadId, buffer) => { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + throw Object.assign(new Error("\u4E0A\u4F20\u5185\u5BB9\u4E3A\u7A7A"), { code: "invalid_file_size" }); + } + const [rows] = await pool.query( + `SELECT id, filename, expected_size, temporary_storage_key, status, expires_at + FROM h5_upload_sessions + WHERE id = ? AND user_id = ? + LIMIT 1`, + [uploadId, userId] + ); + const upload = rows[0]; + if (!upload) throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728"), { code: "upload_not_found" }); + if (upload.status !== "reserved") { + throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u72B6\u6001\u65E0\u6548"), { code: "invalid_upload_state" }); + } + if (asNumber2(upload.expires_at) <= Date.now()) { + throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u5DF2\u8FC7\u671F"), { code: "upload_expired" }); + } + if (buffer.length !== asNumber2(upload.expected_size) || buffer.length > maxFileBytes) { + throw Object.assign(new Error("\u4E0A\u4F20\u5185\u5BB9\u5927\u5C0F\u4E0E\u9884\u671F\u4E0D\u4E00\u81F4"), { + code: "file_size_mismatch" + }); + } + const detectedMimeType = detectMimeType(buffer, upload.filename); + if (!detectedMimeType) { + throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); + } + if (detectedMimeType.startsWith("image/") && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { + throw Object.assign(new Error("\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); + } + const target = absoluteStoragePath(upload.temporary_storage_key); + await fs11.mkdir(path12.dirname(target), { recursive: true }); + await fs11.writeFile(target, buffer, { flag: "wx" }).catch(async (error) => { + if (error?.code !== "EEXIST") throw error; + await fs11.writeFile(target, buffer); + }); + const checksum = crypto6.createHash("sha256").update(buffer).digest("hex"); + await pool.query( + `UPDATE h5_upload_sessions + SET actual_size = ?, detected_mime_type = ?, checksum = ?, status = 'uploaded' + WHERE id = ? AND user_id = ? AND status = 'reserved'`, + [buffer.length, detectedMimeType, checksum, uploadId, userId] + ); + return { sizeBytes: buffer.length, mimeType: detectedMimeType, checksum }; + }; + const completeUpload = async (userId, uploadId) => { + const conn = await pool.getConnection(); + let temporaryPath; + let finalPath; + let publicMirror = null; + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT u.*, c.category_code + FROM h5_upload_sessions u + JOIN h5_space_categories c ON c.id = u.category_id AND c.user_id = u.user_id + WHERE u.id = ? AND u.user_id = ? + LIMIT 1 + FOR UPDATE`, + [uploadId, userId] + ); + const upload = rows[0]; + if (!upload) throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728"), { code: "upload_not_found" }); + if (upload.status === "completed" && upload.completed_asset_id) { + const [existing] = await conn.query( + `SELECT a.*, c.category_code + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id + WHERE a.id = ? AND a.user_id = ?`, + [upload.completed_asset_id, userId] + ); + await conn.commit(); + return existing[0] ? assetResponse(existing[0]) : null; + } + if (upload.status !== "uploaded") { + throw Object.assign(new Error("\u6587\u4EF6\u5185\u5BB9\u5C1A\u672A\u4E0A\u4F20"), { code: "invalid_upload_state" }); + } + if (asNumber2(upload.actual_size) !== asNumber2(upload.expected_size) || !upload.detected_mime_type || !upload.checksum) { + throw Object.assign(new Error("\u4E0A\u4F20\u5185\u5BB9\u4E0D\u5B8C\u6574"), { code: "file_size_mismatch" }); + } + const assetId = idFactory(); + const versionId = idFactory(); + temporaryPath = absoluteStoragePath(upload.temporary_storage_key); + const fileBuffer = await fs11.readFile(temporaryPath); + const scan = runBasicFileScan(fileBuffer, { + filename: upload.filename, + mimeType: upload.detected_mime_type + }); + const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; + const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; + const shouldPublishTempImage = upload.category_code === "public" && upload.detected_mime_type.startsWith("image/") && scan.scanStatus === "passed"; + const finalStorageKey = shouldPublishTempImage ? publicTempImageStorageKey(userId, assetId, upload.detected_mime_type, upload.filename) : upload.temporary_storage_key; + finalPath = absoluteStoragePath(finalStorageKey); + if (finalStorageKey !== upload.temporary_storage_key) { + await fs11.mkdir(path12.dirname(finalPath), { recursive: true }); + await fs11.rename(temporaryPath, finalPath); + } + if (shouldPublishTempImage) { + publicMirror = await writePublicTempImageMirror( + userId, + assetId, + upload.detected_mime_type, + upload.filename, + finalPath + ); + } + const now = Date.now(); + const visibility = visibilityForCategory(upload.category_code); + await conn.query( + `INSERT INTO h5_assets + (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, + display_name, current_version_id, size_bytes, checksum, risk_level, visibility, + status, source_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`, + [ + assetId, + userId, + upload.space_id, + upload.category_id, + assetTypeForMime(upload.detected_mime_type), + upload.detected_mime_type, + upload.filename, + upload.filename, + versionId, + upload.actual_size, + upload.checksum, + scan.riskLevel, + visibility, + assetStatus, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_asset_versions + (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, + created_by, change_note, scan_status, created_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, '\u521D\u59CB\u4E0A\u4F20', ?, ?)`, + [ + versionId, + assetId, + finalStorageKey, + upload.actual_size, + upload.checksum, + upload.detected_mime_type, + userId, + versionScanStatus, + now + ] + ); + await conn.query( + `UPDATE h5_user_spaces + SET reserved_bytes = GREATEST(0, reserved_bytes - ?), + used_bytes = used_bytes + ?, + updated_at = ? + WHERE id = ? AND user_id = ?`, + [upload.reserved_bytes, upload.actual_size, now, upload.space_id, userId] + ); + await conn.query( + `UPDATE h5_upload_sessions + SET status = 'completed', completed_at = ?, completed_asset_id = ? + WHERE id = ? AND user_id = ?`, + [now, assetId, uploadId, userId] + ); + await conn.commit(); + return { + id: assetId, + user_id: userId, + categoryId: upload.category_id, + categoryCode: upload.category_code, + assetType: assetTypeForMime(upload.detected_mime_type), + mimeType: upload.detected_mime_type, + filename: upload.filename, + displayName: upload.filename, + sizeBytes: asNumber2(upload.actual_size), + checksum: upload.checksum, + riskLevel: scan.riskLevel, + visibility, + status: assetStatus, + scanStatus: versionScanStatus, + sourceType: "upload", + createdAt: now, + updatedAt: now, + publicUrl: shouldPublishTempImage ? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename) : null + }; + } catch (error) { + await conn.rollback(); + if (finalPath && finalPath !== temporaryPath) await fs11.rm(finalPath, { force: true }).catch(() => { + }); + if (publicMirror) await fs11.rm(publicMirror, { force: true }).catch(() => { + }); + throw error; + } finally { + conn.release(); + } + }; + const cancelUpload = async (userId, uploadId) => { + const conn = await pool.getConnection(); + let storageKey; + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT id, space_id, reserved_bytes, temporary_storage_key, status + FROM h5_upload_sessions + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE`, + [uploadId, userId] + ); + const upload = rows[0]; + if (!upload) throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728"), { code: "upload_not_found" }); + if (["completed", "cancelled", "expired"].includes(upload.status)) { + await conn.commit(); + return { cancelled: upload.status !== "completed" }; + } + storageKey = upload.temporary_storage_key; + const now = Date.now(); + await conn.query( + `UPDATE h5_user_spaces + SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [upload.reserved_bytes, now, upload.space_id, userId] + ); + await conn.query( + `UPDATE h5_upload_sessions SET status = 'cancelled' WHERE id = ? AND user_id = ?`, + [uploadId, userId] + ); + await conn.commit(); + if (storageKey) await fs11.rm(absoluteStoragePath(storageKey), { force: true }); + return { cancelled: true }; + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => { + if (syncWorkspace && categoryCode && ["oa", "private", "public"].includes(categoryCode)) { + await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => { + }); + } + const filters = [`a.user_id = ?`, `a.status <> 'deleted'`]; + const params = [userId]; + if (categoryId) { + filters.push(`a.category_id = ?`); + params.push(categoryId); + } + if (categoryCode) { + filters.push(`c.category_code = ?`); + params.push(categoryCode); + } + const [rows] = await pool.query( + `SELECT a.*, c.category_code, + ANY_VALUE(p.id) AS source_page_id + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + LEFT JOIN h5_page_versions pv ON pv.bundle_asset_id = a.id + LEFT JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted' AND p.user_id = a.user_id + WHERE ${filters.join(" AND ")} + GROUP BY a.id + ORDER BY a.updated_at DESC + LIMIT 100`, + params + ); + return rows.map(assetResponse); + }; + const deleteAsset = async (userId, assetId) => { + const conn = await pool.getConnection(); + let mirrorCleanup = null; + let storageCleanup = null; + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, a.mime_type, + c.category_code, v.storage_key + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.id = ? AND a.user_id = ? + LIMIT 1 + FOR UPDATE`, + [assetId, userId] + ); + const asset = rows[0]; + if (!asset || asset.status === "deleted") { + throw Object.assign(new Error("\u8D44\u4EA7\u4E0D\u5B58\u5728"), { code: "asset_not_found" }); + } + const [pageReferences] = await conn.query( + `SELECT p.id, p.title, p.status, + EXISTS( + SELECT 1 FROM h5_publish_records pr + WHERE pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online' + ) AS published_online + FROM h5_page_versions pv + JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted' + WHERE pv.content_asset_id = ? OR pv.bundle_asset_id = ? + GROUP BY p.id, p.title, p.status + LIMIT 10`, + [assetId, assetId] + ); + if (pageReferences.length > 0) { + throw Object.assign(new Error("\u8D44\u4EA7\u6B63\u5728\u88AB\u9875\u9762\u4F7F\u7528\uFF0C\u4E0D\u80FD\u5220\u9664"), { + code: "asset_in_use", + details: { + hint: "\u8BF7\u6253\u5F00\u5173\u8054\u9875\u9762\u5E76\u5220\u9664\uFF1B\u5220\u9664\u9875\u9762\u4F1A\u81EA\u52A8\u4E0B\u7EBF\u516C\u5F00\u94FE\u63A5\u5E76\u5220\u9664\u9875\u9762\u5185\u5BB9\u8D44\u4EA7\uFF0C\u4E4B\u540E\u5373\u53EF\u5220\u9664\u539F\u59CB\u8D44\u6599\u3002", + references: pageReferences.map((page) => ({ + type: "page", + id: page.id, + title: page.title, + status: page.status, + publishedOnline: Boolean(page.published_online) + })) + } + }); + } + const now = Date.now(); + await conn.query( + `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [now, now, assetId, userId] + ); + await conn.query( + `UPDATE h5_user_spaces + SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [asset.size_bytes, now, asset.space_id, userId] + ); + await conn.commit(); + if (h5Root) { + mirrorCleanup = { + id: asset.id, + categoryCode: asset.category_code, + filename: asset.original_filename, + mimeType: asset.mime_type + }; + } + if (asset.storage_key) storageCleanup = asset.storage_key; + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + if (mirrorCleanup) { + try { + removeZoneMirror({ + workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }), + categoryCode: mirrorCleanup.categoryCode, + filename: mirrorCleanup.filename + }); + if (/\.html$/i.test(mirrorCleanup.filename)) { + const thumbName = mirrorCleanup.filename.replace(/\.html$/i, ".thumbnail.svg"); + removeZoneMirror({ + workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }), + categoryCode: mirrorCleanup.categoryCode, + filename: thumbName + }); + } + if (mirrorCleanup.categoryCode === "public" && mirrorCleanup.mimeType?.startsWith?.("image/")) { + await removePublicTempImageMirror( + userId, + mirrorCleanup.id, + mirrorCleanup.mimeType, + mirrorCleanup.filename + ); + } + } catch { + } + } + if (storageCleanup) { + try { + await fs11.rm(absoluteStoragePath(storageCleanup), { force: true }); + } catch { + } + } + return { deleted: true }; + }; + const readAsset = async (userId, assetId) => { + const [rows] = await pool.query( + `SELECT a.*, c.category_code, v.storage_key, v.scan_status + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.id = ? AND a.user_id = ? AND a.status <> 'deleted' + LIMIT 1`, + [assetId, userId] + ); + const asset = rows[0]; + if (!asset) throw Object.assign(new Error("\u8D44\u4EA7\u4E0D\u5B58\u5728"), { code: "asset_not_found" }); + assertAssetDownloadable(asset); + return { + asset: assetResponse(asset), + path: await resolveReadableStoragePath(asset.storage_key) + }; + }; + const createChatAsset = async (userId, { categoryCode, buffer, filename, displayName, sourceType = "chat" }) => { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + throw Object.assign(new Error("\u8D44\u4EA7\u5185\u5BB9\u4E3A\u7A7A"), { code: "invalid_file_size" }); + } + if (buffer.length > maxFileBytes) { + throw Object.assign(new Error("\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); + } + const normalizedFilename = normalizeFilename(filename); + const detectedMimeType = detectMimeType(buffer, normalizedFilename); + if (!detectedMimeType) { + throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); + } + if (detectedMimeType.startsWith("image/") && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { + throw Object.assign(new Error("\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); + } + const conn = await pool.getConnection(); + let finalPath; + let publicMirror = null; + try { + await conn.beginTransaction(); + const [categories] = await conn.query( + `SELECT c.id, c.space_id, c.category_code + FROM h5_space_categories c + WHERE c.user_id = ? AND c.category_code = ? + LIMIT 1 + FOR UPDATE`, + [userId, categoryCode] + ); + const category = categories[0]; + if (!category) { + throw Object.assign(new Error("\u5206\u7C7B\u4E0D\u5B58\u5728"), { code: "category_not_found" }); + } + if (!["oa", "private", "public"].includes(category.category_code)) { + throw Object.assign(new Error("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u4FDD\u5B58\u804A\u5929\u8D44\u4EA7"), { + code: "category_not_uploadable" + }); + } + const [spaces] = await conn.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE`, + [category.space_id, userId] + ); + const space = spaces[0]; + if (!space || space.status !== "active") { + throw Object.assign(new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528"), { code: "space_unavailable" }); + } + const available = asNumber2(space.quota_bytes) - asNumber2(space.used_bytes) - asNumber2(space.reserved_bytes); + if (available < buffer.length) { + throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), { + code: "quota_exceeded", + details: { requiredBytes: buffer.length, availableBytes: Math.max(0, available) } + }); + } + const assetId = idFactory(); + const versionId = idFactory(); + const checksum = crypto6.createHash("sha256").update(buffer).digest("hex"); + const scan = runBasicFileScan(buffer, { + filename: normalizedFilename, + mimeType: detectedMimeType + }); + const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; + const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; + const shouldPublishTempImage = category.category_code === "public" && detectedMimeType.startsWith("image/") && scan.scanStatus === "passed"; + const finalStorageKey = shouldPublishTempImage ? publicTempImageStorageKey(userId, assetId, detectedMimeType, normalizedFilename) : path12.posix.join("users", userId, "assets", assetId, "versions", versionId); + finalPath = absoluteStoragePath(finalStorageKey); + await fs11.mkdir(path12.dirname(finalPath), { recursive: true }); + await fs11.writeFile(finalPath, buffer, { flag: "wx" }); + if (shouldPublishTempImage) { + publicMirror = await writePublicTempImageMirror( + userId, + assetId, + detectedMimeType, + normalizedFilename, + finalPath + ); + } + const now = Date.now(); + const visibility = visibilityForCategory(category.category_code); + await conn.query( + `INSERT INTO h5_assets + (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, + display_name, current_version_id, size_bytes, checksum, risk_level, visibility, + status, source_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + assetId, + userId, + category.space_id, + category.id, + assetTypeForMime(detectedMimeType), + detectedMimeType, + normalizedFilename, + displayName || normalizedFilename, + versionId, + buffer.length, + checksum, + scan.riskLevel, + visibility, + assetStatus, + sourceType, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_asset_versions + (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, + created_by, change_note, scan_status, created_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, '\u4ECE\u804A\u5929\u4FDD\u5B58', ?, ?)`, + [ + versionId, + assetId, + finalStorageKey, + buffer.length, + checksum, + detectedMimeType, + userId, + versionScanStatus, + now + ] + ); + await conn.query( + `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [buffer.length, now, category.space_id, userId] + ); + await conn.commit(); + if (detectedMimeType === "text/html") { + scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString("utf8"), { + title: displayName || normalizedFilename, + subtitle: category.category_code.toUpperCase(), + contentStorageKey: finalStorageKey + }); + } + return assetResponse({ + id: assetId, + user_id: userId, + category_id: category.id, + category_code: category.category_code, + asset_type: assetTypeForMime(detectedMimeType), + mime_type: detectedMimeType, + original_filename: normalizedFilename, + display_name: displayName || normalizedFilename, + size_bytes: buffer.length, + checksum, + risk_level: scan.riskLevel, + visibility, + status: assetStatus, + scan_status: versionScanStatus, + source_type: sourceType, + created_at: now, + updated_at: now, + has_thumbnail: detectedMimeType === "text/html" + }); + } catch (error) { + await conn.rollback(); + if (finalPath) await fs11.rm(finalPath, { force: true }).catch(() => { + }); + if (publicMirror) await fs11.rm(publicMirror, { force: true }).catch(() => { + }); + throw error; + } finally { + conn.release(); + } + }; + const renderAssetThumbnail = async (userId, assetId) => { + const { asset, path: assetPath } = await readAsset(userId, assetId); + if (asset.mimeType !== "text/html") { + throw Object.assign(new Error("\u8BE5\u8D44\u4EA7\u4E0D\u652F\u6301\u7F29\u7565\u56FE"), { code: "thumbnail_not_supported" }); + } + const [versions] = await pool.query( + `SELECT storage_key FROM h5_asset_versions + WHERE asset_id = ? AND version_no = 1 + LIMIT 1`, + [assetId] + ); + const html = await fs11.readFile(assetPath, "utf8"); + return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, { + title: asset.displayName, + subtitle: asset.categoryCode?.toUpperCase?.() ?? "HTML", + contentStorageKey: versions[0]?.storage_key + }); + }; + const renderAssetPreview = async (userId, assetId, { downloadPath = null } = {}) => { + const { asset, path: assetPath } = await readAsset(userId, assetId); + if (!canPreviewAsset(asset.mimeType)) { + throw Object.assign(new Error("\u8BE5\u8D44\u4EA7\u4E0D\u652F\u6301\u9884\u89C8"), { code: "preview_not_supported" }); + } + const buffer = await fs11.readFile(assetPath); + const downloadUrl = downloadPath ?? `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`; + return renderAssetPreviewHtml({ asset, buffer, downloadUrl }); + }; + const extractAssetText = async (userId, assetId) => { + const { asset, path: assetPath } = await readAsset(userId, assetId); + const buffer = await fs11.readFile(assetPath); + const extracted = extractAttachmentText(buffer, asset.mimeType, asset.filename); + return { + asset: assetResponse(asset), + ...extracted, + charCount: extracted.text.length + }; + }; + const expireStaleUploads = async (now = Date.now()) => { + const [rows] = await pool.query( + `SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status + FROM h5_upload_sessions + WHERE status IN ('reserved', 'uploaded') AND expires_at <= ? + LIMIT 200`, + [now] + ); + let expired = 0; + for (const upload of rows) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [locked] = await conn.query( + `SELECT id, reserved_bytes, temporary_storage_key, status + FROM h5_upload_sessions + WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded') + LIMIT 1 + FOR UPDATE`, + [upload.id, upload.user_id] + ); + const row = locked[0]; + if (!row) { + await conn.commit(); + continue; + } + await conn.query( + `UPDATE h5_user_spaces + SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [row.reserved_bytes, now, upload.space_id, upload.user_id] + ); + await conn.query( + `UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`, + [upload.id, upload.user_id] + ); + await conn.commit(); + if (row.temporary_storage_key) { + await fs11.rm(absoluteStoragePath(row.temporary_storage_key), { force: true }); + } + expired += 1; + } catch { + await conn.rollback(); + } finally { + conn.release(); + } + } + return expired; + }; + return { + storageRoot, + createUpload, + writeUploadContent, + completeUpload, + cancelUpload, + createChatAsset, + listAssets, + deleteAsset, + readAsset, + renderAssetPreview, + extractAssetText, + renderAssetThumbnail, + syncWorkspaceAssets: workspaceSync.syncUserWorkspace, + expireStaleUploads + }; +} +var assetInternals = { + detectMimeType, + normalizeFilename, + expectedMimeType, + assetTypeForMime +}; + +// mindspace-workspace-sync.mjs +var SKIP_FILENAMES = /* @__PURE__ */ new Set(["index.html", ".tkmindhints", ".goosehints", ".ls_output"]); +function asNumber3(value) { + return Number(value ?? 0); +} +function shouldSyncWorkspaceFilename(filename) { + const normalized = String(filename ?? "").trim(); + if (!normalized || normalized.startsWith(".")) return false; + if (SKIP_FILENAMES.has(normalized)) return false; + if (normalized.endsWith(".thumbnail.svg")) return false; + if (normalized.endsWith(".sh")) return false; + return assetInternals.expectedMimeType(normalized) !== null; +} +async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { + if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; + const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); + let entries; + try { + entries = await fsPromises2.readdir(zoneDir, { withFileTypes: true }); + } catch { + return []; + } + const files = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!shouldSyncWorkspaceFilename(entry.name)) continue; + const absolutePath = path13.join(zoneDir, entry.name); + const stat = await fsPromises2.stat(absolutePath); + files.push({ + filename: entry.name, + absolutePath, + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs + }); + } + return files; +} +function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) { + const absoluteStoragePath = (storageKey) => { + const resolved = path13.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path13.sep}`)) { + throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); + } + return resolved; + }; + const loadExistingAssets = async (userId, categoryId) => { + const [rows] = await pool.query( + `SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status + FROM h5_assets a + WHERE a.user_id = ? AND a.category_id = ? AND a.status <> 'deleted'`, + [userId, categoryId] + ); + return new Map(rows.map((row) => [row.original_filename, row])); + }; + const loadDeletedWorkspaceChecksums = async (userId, categoryId) => { + const [rows] = await pool.query( + `SELECT original_filename, checksum + FROM h5_assets + WHERE user_id = ? AND category_id = ? AND status = 'deleted' AND source_type = 'workspace'`, + [userId, categoryId] + ); + const byFilename = /* @__PURE__ */ new Map(); + for (const row of rows) { + const checksums = byFilename.get(row.original_filename) ?? /* @__PURE__ */ new Set(); + checksums.add(row.checksum); + byFilename.set(row.original_filename, checksums); + } + return byFilename; + }; + const importWorkspaceFile = async (userId, category, file, buffer) => { + const conn = await pool.getConnection(); + let finalPath; + try { + await conn.beginTransaction(); + const [spaces] = await conn.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE`, + [category.space_id, userId] + ); + const space = spaces[0]; + if (!space || space.status !== "active") { + throw Object.assign(new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528"), { code: "space_unavailable" }); + } + const available = asNumber3(space.quota_bytes) - asNumber3(space.used_bytes) - asNumber3(space.reserved_bytes); + if (available < buffer.length) { + throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), { code: "quota_exceeded" }); + } + const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename); + if (!detectedMimeType) { + throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); + } + const scan = runBasicFileScan(buffer, { + filename: file.filename, + mimeType: detectedMimeType + }); + const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; + const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; + const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + const assetId = idFactory(); + const versionId = idFactory(); + const finalStorageKey = path13.posix.join( + "users", + userId, + "assets", + assetId, + "versions", + versionId + ); + finalPath = absoluteStoragePath(finalStorageKey); + await fsPromises2.mkdir(path13.dirname(finalPath), { recursive: true }); + await fsPromises2.writeFile(finalPath, buffer, { flag: "wx" }); + const now = Date.now(); + const visibility = category.category_code === "public" ? "public_candidate" : "private"; + await conn.query( + `INSERT INTO h5_assets + (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, + display_name, current_version_id, size_bytes, checksum, risk_level, visibility, + status, source_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace', ?, ?)`, + [ + assetId, + userId, + category.space_id, + category.id, + assetInternals.assetTypeForMime(detectedMimeType), + detectedMimeType, + file.filename, + path13.basename(file.filename, path13.extname(file.filename)) || file.filename, + versionId, + buffer.length, + checksum, + scan.riskLevel, + visibility, + assetStatus, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_asset_versions + (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, + created_by, change_note, scan_status, created_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, '\u4ECE\u5DE5\u4F5C\u533A\u540C\u6B65', ?, ?)`, + [ + versionId, + assetId, + finalStorageKey, + buffer.length, + checksum, + detectedMimeType, + userId, + versionScanStatus, + now + ] + ); + await conn.query( + `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [buffer.length, now, category.space_id, userId] + ); + await conn.commit(); + return { action: "imported", assetId, filename: file.filename, checksum }; + } catch (error) { + await conn.rollback(); + if (finalPath) await fsPromises2.rm(finalPath, { force: true }).catch(() => { + }); + throw error; + } finally { + conn.release(); + } + }; + const updateWorkspaceFile = async (userId, category, existing, file, buffer) => { + const conn = await pool.getConnection(); + let finalPath; + try { + await conn.beginTransaction(); + const [spaces] = await conn.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE`, + [category.space_id, userId] + ); + const space = spaces[0]; + if (!space || space.status !== "active") { + throw Object.assign(new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528"), { code: "space_unavailable" }); + } + const sizeDelta = buffer.length - asNumber3(existing.size_bytes); + const available = asNumber3(space.quota_bytes) - asNumber3(space.used_bytes) - asNumber3(space.reserved_bytes); + if (sizeDelta > 0 && available < sizeDelta) { + throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), { code: "quota_exceeded" }); + } + const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename); + if (!detectedMimeType) { + throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); + } + const scan = runBasicFileScan(buffer, { + filename: file.filename, + mimeType: detectedMimeType + }); + const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; + const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; + const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + const [versionRows] = await conn.query( + `SELECT COALESCE(MAX(version_no), 0) AS max_version + FROM h5_asset_versions + WHERE asset_id = ?`, + [existing.id] + ); + const versionNo = asNumber3(versionRows[0]?.max_version) + 1; + const versionId = idFactory(); + const finalStorageKey = path13.posix.join( + "users", + userId, + "assets", + existing.id, + "versions", + versionId + ); + finalPath = absoluteStoragePath(finalStorageKey); + await fsPromises2.mkdir(path13.dirname(finalPath), { recursive: true }); + await fsPromises2.writeFile(finalPath, buffer, { flag: "wx" }); + const now = Date.now(); + await conn.query( + `UPDATE h5_assets + SET current_version_id = ?, size_bytes = ?, checksum = ?, mime_type = ?, + asset_type = ?, risk_level = ?, status = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [ + versionId, + buffer.length, + checksum, + detectedMimeType, + assetInternals.assetTypeForMime(detectedMimeType), + scan.riskLevel, + assetStatus, + now, + existing.id, + userId + ] + ); + await conn.query( + `INSERT INTO h5_asset_versions + (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, + created_by, change_note, scan_status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, '\u5DE5\u4F5C\u533A\u6587\u4EF6\u66F4\u65B0', ?, ?)`, + [ + versionId, + existing.id, + versionNo, + finalStorageKey, + buffer.length, + checksum, + detectedMimeType, + userId, + versionScanStatus, + now + ] + ); + if (sizeDelta !== 0) { + await conn.query( + `UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [sizeDelta, now, category.space_id, userId] + ); + } + await conn.commit(); + return { action: "updated", assetId: existing.id, filename: file.filename, checksum }; + } catch (error) { + await conn.rollback(); + if (finalPath) await fsPromises2.rm(finalPath, { force: true }).catch(() => { + }); + throw error; + } finally { + conn.release(); + } + }; + const syncCategory = async (userId, categoryCode) => { + if (!h5Root) return { imported: 0, updated: 0, skipped: 0 }; + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const files = await listWorkspaceZoneFiles(workspaceRoot, categoryCode); + const [categories] = await pool.query( + `SELECT c.id, c.space_id, c.category_code + FROM h5_space_categories c + WHERE c.user_id = ? AND c.category_code = ? + LIMIT 1`, + [userId, categoryCode] + ); + const category = categories[0]; + if (!category) return { imported: 0, updated: 0, skipped: 0 }; + const existingByName = await loadExistingAssets(userId, category.id); + const deletedWorkspaceChecksums = await loadDeletedWorkspaceChecksums(userId, category.id); + let imported = 0; + let updated = 0; + let skipped = 0; + for (const file of files) { + if (file.sizeBytes <= 0 || file.sizeBytes > maxFileBytes) { + skipped += 1; + continue; + } + const buffer = await fsPromises2.readFile(file.absolutePath); + const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + if (deletedWorkspaceChecksums.get(file.filename)?.has(checksum)) { + skipped += 1; + continue; + } + const existing = existingByName.get(file.filename); + if (existing?.checksum === checksum) { + skipped += 1; + continue; + } + if (existing) { + await updateWorkspaceFile(userId, category, existing, file, buffer); + existing.checksum = checksum; + updated += 1; + } else { + await importWorkspaceFile(userId, category, file, buffer); + existingByName.set(file.filename, { checksum }); + imported += 1; + } + } + return { imported, updated, skipped }; + }; + const syncUserWorkspace = async (userId, { categoryCode } = {}) => { + const codes = categoryCode ? [categoryCode] : UPLOAD_ZONE_CODES; + let imported = 0; + let updated = 0; + let skipped = 0; + for (const code of codes) { + const result = await syncCategory(userId, code); + imported += result.imported; + updated += result.updated; + skipped += result.skipped; + } + return { imported, updated, skipped }; + }; + return { syncUserWorkspace, syncCategory, listWorkspaceZoneFiles }; +} +function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey }) { + if (!publishRoot || !syncUserWorkspaceByDirKey) return () => { + }; + const pending = /* @__PURE__ */ new Map(); + const schedule = (dirKey, categoryCode) => { + const key = `${dirKey}:${categoryCode ?? "all"}`; + const existing = pending.get(key); + if (existing) clearTimeout(existing); + pending.set( + key, + setTimeout(() => { + pending.delete(key); + void syncUserWorkspaceByDirKey(dirKey, categoryCode ? { categoryCode } : {}).catch(() => { + }); + }, 600) + ); + }; + const watchZoneDir = (dirKey, zoneDir, categoryCode) => { + try { + fs12.watch(zoneDir, (_event, filename) => { + if (!filename || !shouldSyncWorkspaceFilename(filename)) return; + schedule(dirKey, categoryCode); + }); + } catch { + } + }; + const attachUser = async (dirKey) => { + const userDir = path13.join(publishRoot, dirKey); + try { + await fsPromises2.access(userDir); + } catch { + return; + } + for (const categoryCode of UPLOAD_ZONE_CODES) { + watchZoneDir(dirKey, resolveZoneDir(userDir, categoryCode), categoryCode); + } + }; + for (const entry of fs12.readdirSync(publishRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === "wiki" || entry.name.startsWith(".")) continue; + void attachUser(entry.name); + } + try { + fs12.watch(publishRoot, (_event, filename) => { + if (!filename) return; + const userDir = path13.join(publishRoot, filename); + if (fs12.existsSync(userDir) && fs12.statSync(userDir).isDirectory()) { + void attachUser(filename); + } + }); + } catch { + } + return () => { + for (const timer of pending.values()) clearTimeout(timer); + pending.clear(); + }; +} + +// api-response.mjs +import crypto8 from "node:crypto"; +function createRequestId(existing) { + if (typeof existing === "string" && existing.trim()) return existing.trim(); + return `req_${crypto8.randomUUID()}`; +} +function attachRequestId(req, res, next) { + const requestId = createRequestId(req.get("x-request-id")); + req.requestId = requestId; + res.setHeader("X-Request-Id", requestId); + next(); +} +function sendJson(res, req, status, body) { + const requestId = req?.requestId ?? createRequestId(); + return res.status(status).json({ ...body, request_id: requestId }); +} +function sendData(res, req, data, status = 200) { + return sendJson(res, req, status, { data }); +} +function sendError(res, req, status, code, message, details) { + return sendJson(res, req, status, { + error: { + code, + message, + ...details ? { details } : {} + } + }); +} + +// mindspace-audit.mjs +function createMindSpaceAuditWriter(pool) { + const write = async ({ + userId, + action, + objectType, + objectId, + ip = null, + result = "success", + riskLevel = null, + now = Date.now() + }) => { + if (!pool) return; + await pool.query( + `INSERT INTO h5_mindspace_audit_logs + (user_id, action, object_type, object_id, ip, result, risk_level, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, action, objectType, objectId, ip, result, riskLevel, now] + ); + }; + return { write }; +} + +// mindspace-flags.mjs +function mindspaceFlags(env = process.env) { + const enabled = env.MINDSPACE_ENABLED !== "false"; + return { + mindspace_enabled: enabled, + mindspace_upload_enabled: enabled && env.MINDSPACE_UPLOAD_ENABLED !== "false", + mindspace_publish_enabled: enabled && env.MINDSPACE_PUBLISH_ENABLED === "true", + mindspace_private_enabled: enabled && env.MINDSPACE_PRIVATE_ENABLED !== "false", + mindspace_agent_jobs_enabled: enabled && env.MINDSPACE_AGENT_JOBS_ENABLED === "true" + }; +} +function assertMindSpaceRoute(flags, route) { + if (!flags.mindspace_enabled) { + throw Object.assign(new Error("MindSpace \u529F\u80FD\u672A\u542F\u7528"), { code: "feature_disabled" }); + } + if (route === "upload" && !flags.mindspace_upload_enabled) { + throw Object.assign(new Error("\u4E0A\u4F20\u529F\u80FD\u672A\u542F\u7528"), { code: "feature_disabled" }); + } + if (route === "agent" && !flags.mindspace_agent_jobs_enabled) { + throw Object.assign(new Error("Agent \u4EFB\u52A1\u529F\u80FD\u672A\u542F\u7528"), { code: "feature_disabled" }); + } +} + +// mindspace-pages.mjs +import crypto10 from "node:crypto"; +import fs13 from "node:fs/promises"; +import path14 from "node:path"; + +// mindspace-content-scan.mjs +import crypto9 from "node:crypto"; +var RISK_ORDER = ["none", "low", "medium", "high", "critical"]; +var HTML_TAG_PATTERN = /<[^>]+>/; +var PRIVATE_URL_PATTERN = /(file:\/\/[^\s"'<>]+|\/api\/mindspace\/v1\/assets\/[a-z0-9-]+(?:\/[a-z-]+)?|\/users\/[^\s"'<>]+)/gi; +var TEXT_RULES = [ + { + type: "id_card", + label: "\u8EAB\u4EFD\u8BC1\u53F7", + riskLevel: "high", + blocking: true, + pattern: /(? `${value.slice(0, 6)}********${value.slice(-4)}`, + replacement: (value) => `${value.slice(0, 6)}********${value.slice(-4)}` + }, + { + type: "bank_card", + label: "\u94F6\u884C\u5361\u53F7", + riskLevel: "high", + blocking: true, + pattern: /(? `${value.slice(0, 4)} **** **** ${value.slice(-4)}`, + replacement: (value) => `${value.slice(0, 4)} **** **** ${value.slice(-4)}` + }, + { + type: "phone", + label: "\u624B\u673A\u53F7", + riskLevel: "medium", + blocking: false, + pattern: /(? `${value.slice(0, 3)}****${value.slice(-4)}`, + replacement: (value) => `${value.slice(0, 3)}****${value.slice(-4)}` + }, + { + type: "email", + label: "\u90AE\u7BB1\u5730\u5740", + riskLevel: "medium", + blocking: false, + pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + mask: (value) => `${value.slice(0, 2)}***@***`, + replacement: () => "[\u90AE\u7BB1\u5730\u5740]" + }, + { + type: "api_key", + label: "API Key", + riskLevel: "critical", + blocking: true, + pattern: /\b(?:sk|rk|pk)_(?:test_|live_|proj_)?[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}\b|ghp_[A-Za-z0-9]{20,}\b|AIza[0-9A-Za-z\-_]{20,}\b/g, + mask: (value) => `${value.slice(0, 4)}********${value.slice(-4)}`, + replacement: () => "[API_KEY]" + }, + { + type: "access_token", + label: "\u8BBF\u95EE\u4EE4\u724C", + riskLevel: "critical", + blocking: true, + pattern: /\b(?:access[_-]?token|refresh[_-]?token|authorization)\b\s*[:=]?\s*(?:bearer\s+)?[A-Za-z0-9\-_=+.]{16,}/gi, + mask: (value) => `${value.slice(0, 10)}********`, + replacement: (value) => value.replace(/[A-Za-z0-9\-_=+.]{16,}$/i, "[TOKEN]") + }, + { + type: "private_key", + label: "\u79C1\u94A5", + riskLevel: "critical", + blocking: true, + pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + mask: () => "-----BEGIN PRIVATE KEY-----********", + replacement: () => "[PRIVATE_KEY]" + }, + { + type: "external_link", + label: "\u5916\u90E8\u94FE\u63A5", + riskLevel: "low", + blocking: false, + pattern: /https?:\/\/[^\s<>"')]+/gi, + mask: (value) => value.slice(0, 80), + replacement: (value) => value + } +]; +var HTML_RULES = [ + { + type: "html_script", + label: "\u811A\u672C\u6807\u7B7E", + riskLevel: "critical", + blocking: true, + pattern: //gi, + mask: () => "", + replacement: () => "" + }, + { + type: "html_inline_handler", + label: "\u5185\u8054\u4E8B\u4EF6", + riskLevel: "high", + blocking: true, + pattern: /\son[a-z]+\s*=\s*(['"]).*?\1/gi, + mask: () => "on*=\u2026", + replacement: () => "" + }, + { + type: "html_javascript_url", + label: "javascript \u94FE\u63A5", + riskLevel: "high", + blocking: true, + pattern: /(href|src)\s*=\s*(['"])\s*javascript:[\s\S]*?\2/gi, + mask: (value) => value.slice(0, 40), + replacement: (_value, match) => `${match[1]}=${match[2]}#${match[2]}` + }, + { + type: "html_forbidden_embed", + label: "\u5D4C\u5165\u5F0F\u5916\u90E8\u5185\u5BB9", + riskLevel: "high", + blocking: true, + pattern: /<(iframe|object|embed)\b[\s\S]*?>[\s\S]*?(?:<\/\1>|$)/gi, + mask: (value, match) => `<${match[1]}>\u2026`, + replacement: () => "" + }, + { + type: "html_form_action", + label: "\u8868\u5355\u63D0\u4EA4", + riskLevel: "high", + blocking: true, + pattern: /[\s\S]*?<\/form>/gi, + mask: () => "
\u2026
", + replacement: () => "" + }, + { + type: "html_meta_refresh", + label: "\u9875\u9762\u8DF3\u8F6C", + riskLevel: "medium", + blocking: true, + pattern: /]*http-equiv\s*=\s*(['"])refresh\1[^>]*>/gi, + mask: () => '', + replacement: () => "" + }, + { + type: "private_resource_reference", + label: "\u79C1\u6709\u8D44\u6E90\u5F15\u7528", + riskLevel: "high", + blocking: true, + pattern: PRIVATE_URL_PATTERN, + mask: (value) => value.slice(0, 48), + replacement: () => "#private-resource-redacted" + } +]; +var ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES = /* @__PURE__ */ new Set([ + "html_script", + "html_inline_handler" +]); +var TRUSTED_EXTERNAL_HOSTS = /* @__PURE__ */ new Set([ + "fonts.googleapis.com", + "fonts.gstatic.com", + "images.unsplash.com" +]); +function replacePrivateResourceReferences(content, replacement) { + const source = String(content ?? ""); + if (!source) return source; + const replacer = typeof replacement === "function" ? replacement : () => String(replacement ?? ""); + return source.replace(PRIVATE_URL_PATTERN, (...args) => replacer(...args)); +} +function inferFormat(content, format) { + if (format === "html") return "html"; + return HTML_TAG_PATTERN.test(String(content ?? "")) ? "html" : "text"; +} +function isTrustedExternalUrl(value) { + try { + return TRUSTED_EXTERNAL_HOSTS.has(new URL(value).hostname); + } catch { + return false; + } +} +function riskMax(left, right) { + return RISK_ORDER.indexOf(right) > RISK_ORDER.indexOf(left) ? right : left; +} +function uniqueMatches(content, pattern) { + return [ + ...new Map( + [...String(content).matchAll(pattern)].map((match) => [ + match[0].toLowerCase(), + match + ]) + ).values() + ]; +} +function applyRule(content, rule, findings, redactions, options = {}) { + let matches = uniqueMatches(content, rule.pattern); + if (rule.type === "external_link") { + matches = matches.filter((match) => !isTrustedExternalUrl(match[0])); + } + if (!matches.length) return content; + const blocking = options.allowHtmlActiveContent && ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES.has(rule.type) ? false : rule.blocking; + findings.push({ + id: crypto9.createHash("sha256").update(`${rule.type}:${matches[0][0]}`).digest("hex").slice(0, 24), + type: rule.type, + label: rule.label, + riskLevel: rule.riskLevel, + occurrenceCount: matches.length, + sampleMasked: rule.mask(matches[0][0], matches[0]), + blocking + }); + let next = String(content); + if (typeof rule.replacement === "function") { + next = next.replace(rule.pattern, (...args) => rule.replacement(...args)); + redactions.push(rule.type); + } + return next; +} +function finalizeResult(findings) { + const riskLevel = findings.reduce((highest, finding) => riskMax(highest, finding.riskLevel), "none"); + return { + status: findings.some((finding) => finding.blocking) ? "blocked" : findings.length ? "warned" : "passed", + riskLevel, + findings, + allowed: !findings.some((finding) => finding.blocking) + }; +} +function scanContent(content, options = {}) { + const format = inferFormat(content, options.format); + const findings = []; + const working = String(content ?? ""); + for (const rule of TEXT_RULES) { + applyRule(working, rule, findings, [], options); + } + if (format === "html") { + for (const rule of HTML_RULES) { + applyRule(working, rule, findings, [], options); + } + } + return finalizeResult(findings); +} +function redactContent(content, options = {}) { + const format = inferFormat(content, options.format); + const findings = []; + const redactions = []; + let next = String(content ?? ""); + for (const rule of TEXT_RULES) { + next = applyRule(next, rule, findings, redactions); + } + if (format === "html") { + for (const rule of HTML_RULES) { + next = applyRule(next, rule, findings, redactions); + } + } + const result = finalizeResult(findings); + return { + ...result, + content: next, + format, + redactionCount: redactions.length, + changed: next !== String(content ?? "") + }; +} + +// mindspace-cover-meta.mjs +function escapeMetaAttribute(value) { + return String(value ?? "").replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<"); +} +function parseMindspaceCoverMeta(html) { + const tag = String(html).match(/]*name=["']mindspace-cover["'][^>]*>/i)?.[0]; + if (!tag) return null; + const contentMatch = tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i); + const raw = contentMatch?.[2] ?? contentMatch?.[1]; + if (!raw) return null; + try { + return JSON.parse(raw.replaceAll(""", '"')); + } catch { + return null; + } +} +function upsertMindspaceCoverMeta(html, coverMeta) { + const payload = { ...parseMindspaceCoverMeta(html) ?? {}, ...coverMeta }; + const metaTag = ``; + const source = String(html ?? ""); + if (/]*name=["']mindspace-cover["'][^>]*>/i.test(source)) { + return source.replace(/]*name=["']mindspace-cover["'][^>]*>/i, metaTag); + } + if (/]*>/i.test(source)) { + return source.replace(/]*)>/i, ` + ${metaTag}`); + } + if (/]*>/i.test(source)) { + return source.replace(/]*)>/i, `${metaTag}`); + } + return `${metaTag}${source}`; +} +function normalizeCoverMetaSuggestion(raw) { + const payload = raw && typeof raw === "object" ? raw : {}; + const next = {}; + if (payload.tag != null) next.tag = String(payload.tag).trim().slice(0, 12); + if (payload.emoji != null) next.emoji = String(payload.emoji).trim().slice(0, 4); + if (payload.accent != null) next.accent = String(payload.accent).trim(); + if (payload.accent2 != null) next.accent2 = String(payload.accent2).trim(); + if (payload.subtitle != null) next.subtitle = String(payload.subtitle).trim().slice(0, 80); + if (payload.cover != null) next.cover = String(payload.cover).trim().slice(0, 512); + if (payload.image != null && !next.cover) next.cover = String(payload.image).trim().slice(0, 512); + if (payload.mood != null) next.mood = String(payload.mood).trim().slice(0, 24); + return next; +} + +// mindspace-pages.mjs +var MAX_TITLE_LENGTH = 255; +var MAX_SUMMARY_LENGTH = 1e3; +var MAX_CONTENT_BYTES = 1024 * 1024; +var TEMPLATE_IDS = /* @__PURE__ */ new Set(["editorial", "report", "profile", "knowledge-card", "static-html"]); +var SAVE_CATEGORY_CODES = /* @__PURE__ */ new Set(["draft", "oa", "private", "public"]); +var PRIVATE_ASSET_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; +function asNumber4(value) { + return Number(value ?? 0); +} +function pageError(message, code, details) { + return Object.assign(new Error(message), { code, details }); +} +function normalizeText(value, maxLength, fieldName) { + const text = String(value ?? "").normalize("NFKC").trim(); + if (!text) throw pageError(`${fieldName}\u4E0D\u80FD\u4E3A\u7A7A`, "invalid_page_input"); + if (text.length > maxLength) { + throw pageError(`${fieldName}\u8D85\u8FC7\u957F\u5EA6\u9650\u5236`, "invalid_page_input"); + } + return text; +} +function normalizePageInput(input) { + const title = normalizeText(input.title, MAX_TITLE_LENGTH, "\u6807\u9898"); + const content = normalizeText(input.content, MAX_CONTENT_BYTES, "\u9875\u9762\u5185\u5BB9"); + const contentBytes = Buffer.byteLength(content, "utf8"); + if (contentBytes > MAX_CONTENT_BYTES) { + throw pageError("\u9875\u9762\u5185\u5BB9\u8D85\u8FC7 1 MB \u9650\u5236", "page_content_too_large"); + } + const summary = String(input.summary ?? "").normalize("NFKC").trim().slice(0, MAX_SUMMARY_LENGTH); + const contentFormat = input.contentFormat === "html" ? "html" : "markdown"; + const templateId = contentFormat === "html" ? "static-html" : TEMPLATE_IDS.has(input.templateId) ? input.templateId : "editorial"; + const plainSummary = contentFormat === "html" ? content.replace(//gi, " ").replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 180) : content.replace(/\s+/g, " ").slice(0, 180); + return { + title, + summary: summary || plainSummary, + content, + contentBytes, + templateId, + contentFormat, + pageType: contentFormat === "html" ? "html" : input.pageType || "article", + categoryCode: SAVE_CATEGORY_CODES.has(input.categoryCode) ? input.categoryCode : "draft" + }; +} +function extensionForMime(mimeType, filename = "") { + const ext = path14.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); + if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === "jpeg" ? "jpg" : ext; + if (mimeType === "image/jpeg") return "jpg"; + if (mimeType === "image/png") return "png"; + if (mimeType === "image/webp") return "webp"; + if (mimeType === "image/gif") return "gif"; + return "bin"; +} +function pageResponse(row) { + return { + id: row.id, + categoryId: row.category_id, + categoryCode: row.category_code, + sourceSessionId: row.source_session_id, + sourceMessageId: row.source_message_id, + sourceAssetId: row.source_asset_id, + title: row.title, + summary: row.summary ?? "", + pageType: row.page_type, + contentFormat: row.page_type === "html" ? "html" : "markdown", + hasThumbnail: row.page_type === "html", + templateId: row.template_id, + status: row.status, + visibility: row.visibility, + publicationAccessMode: row.pub_access_mode ?? null, + publicationUrl: row.pub_public_url ?? null, + currentVersionId: row.current_version_id, + versionNo: asNumber4(row.version_no), + content: row.content, + createdAt: asNumber4(row.created_at), + updatedAt: asNumber4(row.updated_at) + }; +} +function escapeHtml2(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} +function renderContent(content) { + return content.split(/\n{2,}/).map((block) => { + const trimmed = block.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("### ")) return `

${escapeHtml2(trimmed.slice(4))}

`; + if (trimmed.startsWith("## ")) return `

${escapeHtml2(trimmed.slice(3))}

`; + if (trimmed.startsWith("# ")) return `

${escapeHtml2(trimmed.slice(2))}

`; + return `

${escapeHtml2(trimmed).replaceAll("\n", "
")}

`; + }).join("\n"); +} +function renderPreviewHtml(page) { + const templateClass = TEMPLATE_IDS.has(page.templateId) ? page.templateId : "editorial"; + return ` + + + + + + ${escapeHtml2(page.title)} + + + +
+
MINDSPACE DRAFT \xB7 V${page.versionNo}
+

${escapeHtml2(page.title)}

+
${escapeHtml2(page.summary)}
+
${renderContent(page.content)}
+
+ +`; +} +function renderHtmlPreview(html) { + const csp = ``; + const previewShell = ''; + const source = String(html); + if (/]*>/i.test(source)) { + return source.replace(/]*)>/i, `${csp}${previewShell}`); + } + if (/]*>/i.test(source)) { + return source.replace(/]*)>/i, `${csp}${previewShell}`); + } + return `${csp}${previewShell}${source}`; +} +function previewContentSecurityPolicy(contentFormat) { + if (contentFormat === "html") { + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; script-src 'none'"; + } + return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"; +} +function isHtmlPage(page) { + return page.pageType === "html" || page.page_type === "html" || page.contentFormat === "html"; +} +function renderPublicationHtml(page) { + if (isHtmlPage(page)) { + return renderHtmlPreview(page.content); + } + return renderPreviewHtml({ + title: page.title, + summary: page.summary ?? "", + templateId: page.template_id, + versionNo: page.version_no, + content: page.content + }).replace( + `MINDSPACE DRAFT \xB7 V${page.version_no}`, + `MINDSPACE \xB7 V${page.version_no}` + ); +} +function createPageService(pool, options = {}) { + const storageRoot = path14.resolve(options.storageRoot ?? path14.join(process.cwd(), "data", "mindspace")); + const h5Root = options.h5Root ? path14.resolve(options.h5Root) : null; + const idFactory = options.idFactory ?? (() => crypto10.randomUUID()); + const resolveWorkspacePublishDir = async (userId) => { + if (!h5Root) return null; + const [rows] = await pool.query( + `SELECT username FROM h5_users WHERE id = ? LIMIT 1`, + [userId] + ); + const username = rows[0]?.username; + if (!username) return null; + return resolvePublishDir(h5Root, { id: userId }); + }; + const absoluteStoragePath = (storageKey) => { + const resolved = path14.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path14.sep}`)) { + throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); + } + return resolved; + }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? "").replace(/\\/g, "/"); + const withoutMd = normalized.endsWith(".md") ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}` + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs13.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === "ENOENT") { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); + }; + const writeVersionContent = async (userId, assetId, versionId, content) => { + const storageKey = path14.posix.join( + "users", + userId, + "pages", + assetId, + "versions", + `${versionId}.md` + ); + const target = absoluteStoragePath(storageKey); + await fs13.mkdir(path14.dirname(target), { recursive: true }); + await fs13.writeFile(target, content, { flag: "wx" }); + return { storageKey, target }; + }; + const createVersion = async (userId, input, source = {}) => { + let pageInput = input; + if (input.pageId && input.contentFormat == null) { + const [existingPages] = await pool.query( + `SELECT page_type FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1`, + [input.pageId, userId] + ); + if (existingPages[0]?.page_type === "html") { + pageInput = { ...input, contentFormat: "html" }; + } + } + const normalized = normalizePageInput(pageInput); + const conn = await pool.getConnection(); + let writtenPath; + try { + await conn.beginTransaction(); + const [spaces] = await conn.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces WHERE user_id = ? LIMIT 1 FOR UPDATE`, + [userId] + ); + const space = spaces[0]; + if (!space || space.status !== "active") { + throw pageError("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528", "space_unavailable"); + } + const available = asNumber4(space.quota_bytes) - asNumber4(space.used_bytes) - asNumber4(space.reserved_bytes); + if (available < normalized.contentBytes) { + throw pageError("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3", "quota_exceeded", { + requiredBytes: normalized.contentBytes, + availableBytes: Math.max(0, available) + }); + } + const [categories] = await conn.query( + `SELECT id, category_code FROM h5_space_categories + WHERE space_id = ? AND user_id = ? AND category_code = ? + LIMIT 1 FOR UPDATE`, + [space.id, userId, normalized.categoryCode] + ); + const category = categories[0]; + if (!category) throw pageError("\u76EE\u6807\u5206\u7C7B\u4E0D\u5B58\u5728", "category_not_found"); + if (normalized.categoryCode !== "draft") { + throw pageError("\u9875\u9762\u8BB0\u5F55\u53EA\u80FD\u4FDD\u5B58\u5728\u9875\u9762\u8349\u7A3F\u533A", "category_not_pageable"); + } + let pageId = input.pageId; + let nextVersionNo = 1; + if (pageId) { + const [pages] = await conn.query( + `SELECT p.id, p.current_version_id, pv.version_no + FROM h5_page_records p + LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1 FOR UPDATE`, + [pageId, userId] + ); + const page = pages[0]; + if (!page) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); + if (asNumber4(input.expectedVersion) !== asNumber4(page.version_no)) { + throw pageError("\u9875\u9762\u5DF2\u88AB\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5", "version_conflict", { + currentVersion: asNumber4(page.version_no) + }); + } + nextVersionNo = asNumber4(page.version_no) + 1; + } else { + pageId = idFactory(); + } + const contentAssetId = idFactory(); + const assetVersionId = idFactory(); + const pageVersionId = idFactory(); + const checksum = crypto10.createHash("sha256").update(normalized.content).digest("hex"); + const stored = await writeVersionContent( + userId, + contentAssetId, + assetVersionId, + normalized.content + ); + writtenPath = stored.target; + const now = Date.now(); + const contentAssetType = normalized.contentFormat === "html" ? "html" : "markdown"; + const contentMimeType = normalized.contentFormat === "html" ? "text/html" : "text/markdown"; + const contentExtension = normalized.contentFormat === "html" ? "html" : "md"; + await conn.query( + `INSERT INTO h5_assets + (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, + display_name, current_version_id, size_bytes, checksum, risk_level, visibility, + status, source_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'none', + 'private', 'ready', ?, ?, ?)`, + [ + contentAssetId, + userId, + space.id, + category.id, + contentAssetType, + contentMimeType, + `${pageId}-v${nextVersionNo}.${contentExtension}`, + `${normalized.title} \xB7 v${nextVersionNo}`, + assetVersionId, + normalized.contentBytes, + checksum, + source.type ?? "generated", + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_asset_versions + (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, + created_by, change_note, scan_status, created_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'pending', ?)`, + [ + assetVersionId, + contentAssetId, + stored.storageKey, + normalized.contentBytes, + checksum, + contentMimeType, + userId, + input.changeNote ?? "\u4FDD\u5B58\u9875\u9762\u8349\u7A3F", + now + ] + ); + if (nextVersionNo === 1) { + await conn.query( + `INSERT INTO h5_page_records + (id, user_id, space_id, category_id, source_session_id, source_message_id, + source_asset_id, title, summary, page_type, template_id, draft_content_ref, + current_version_id, status, visibility, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`, + [ + pageId, + userId, + space.id, + category.id, + source.sessionId ?? null, + source.messageId ?? null, + source.assetId ?? null, + normalized.title, + normalized.summary, + normalized.pageType, + normalized.templateId, + stored.storageKey, + pageVersionId, + now, + now + ] + ); + } + await conn.query( + `INSERT INTO h5_page_versions + (id, page_id, version_no, content_asset_id, source_snapshot_json, + created_by, change_note, immutable, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`, + [ + pageVersionId, + pageId, + nextVersionNo, + contentAssetId, + JSON.stringify(source.snapshot ?? {}), + userId, + input.changeNote ?? "\u4FDD\u5B58\u9875\u9762\u8349\u7A3F", + now + ] + ); + if (nextVersionNo > 1) { + await conn.query( + `UPDATE h5_page_records + SET title = ?, summary = ?, page_type = ?, template_id = ?, + draft_content_ref = ?, current_version_id = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [ + normalized.title, + normalized.summary, + normalized.pageType, + normalized.templateId, + stored.storageKey, + pageVersionId, + now, + pageId, + userId + ] + ); + } + await conn.query( + `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [normalized.contentBytes, now, space.id, userId] + ); + await conn.commit(); + if (normalized.contentFormat === "html") { + const workspaceHtmlRelativePath = source.snapshot?.relative_path ?? null; + const workspacePublishDir = workspaceHtmlRelativePath ? await resolveWorkspacePublishDir(userId) : null; + if (workspacePublishDir && workspaceHtmlRelativePath) { + await ensureWorkspaceHtmlThumbnail( + workspacePublishDir, + workspaceHtmlRelativePath, + normalized.content, + { + title: normalized.title, + subtitle: normalized.summary || `\u8349\u7A3F v${nextVersionNo}`, + contentStorageKey: stored.storageKey + } + ).catch(() => { + }); + } + const thumbMeta = { + title: normalized.title, + subtitle: normalized.summary || `\u8349\u7A3F v${nextVersionNo}`, + contentStorageKey: stored.storageKey + }; + queueMicrotask(() => { + void ensurePageThumbnail({ + storageRoot, + pageThumbnailStorageKey: pageThumbnailKey(userId, pageId), + html: normalized.content, + meta: thumbMeta, + workspacePublishDir, + workspaceHtmlRelativePath + }).catch(() => { + }); + }); + } + return getPage(userId, pageId); + } catch (error) { + await conn.rollback(); + if (writtenPath) await fs13.rm(writtenPath, { force: true }).catch(() => { + }); + throw error; + } finally { + conn.release(); + } + }; + const findPageBySourceAsset = async (userId, assetId) => { + if (!assetId) return null; + const [rows] = await pool.query( + `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url + FROM h5_page_records p + JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id + LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id + LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online' + WHERE p.user_id = ? AND p.source_asset_id = ? AND p.status <> 'deleted' + ORDER BY p.updated_at DESC + LIMIT 1`, + [userId, assetId] + ); + return rows[0] ? pageResponse(rows[0]) : null; + }; + const listPages = async (userId, filters = {}) => { + const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`]; + const params = [userId]; + if (filters.status) { + clauses.push(`p.status = ?`); + params.push(filters.status); + } + const [rows] = await pool.query( + `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url + FROM h5_page_records p + JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id + LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id + LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online' + WHERE ${clauses.join(" AND ")} + ORDER BY p.updated_at DESC LIMIT 100`, + params + ); + return rows.map(pageResponse); + }; + async function getPage(userId, pageId) { + const [rows] = await pool.query( + `SELECT p.*, c.category_code, pv.version_no, av.storage_key + FROM h5_page_records p + JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id + JOIN h5_page_versions pv ON pv.id = p.current_version_id + JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1 + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId] + ); + const row = rows[0]; + if (!row) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); + const storagePath = await resolveReadableStoragePath(row.storage_key); + const content = await fs13.readFile(storagePath, "utf8"); + return pageResponse({ ...row, content }); + } + const listVersions = async (userId, pageId) => { + await getPage(userId, pageId); + const [rows] = await pool.query( + `SELECT pv.id, pv.version_no, pv.change_note, pv.immutable, pv.created_at + FROM h5_page_versions pv + JOIN h5_page_records p ON p.id = pv.page_id + WHERE pv.page_id = ? AND p.user_id = ? + ORDER BY pv.version_no DESC`, + [pageId, userId] + ); + return rows.map((row) => ({ + id: row.id, + versionNo: asNumber4(row.version_no), + changeNote: row.change_note, + immutable: Boolean(row.immutable), + createdAt: asNumber4(row.created_at) + })); + }; + const redactPage = async (userId, pageId, input = {}) => { + const page = await getPage(userId, pageId); + if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) { + throw pageError("\u53EA\u80FD\u57FA\u4E8E\u5F53\u524D\u7248\u672C\u4FEE\u590D\u9875\u9762", "version_conflict", { + currentVersion: page.currentVersionId + }); + } + if (input.expectedVersion != null && asNumber4(input.expectedVersion) !== asNumber4(page.versionNo)) { + throw pageError("\u9875\u9762\u5DF2\u88AB\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5", "version_conflict", { + currentVersion: asNumber4(page.versionNo) + }); + } + const contentFormat = page.contentFormat === "html" ? "html" : "markdown"; + const scanFormat = contentFormat === "html" ? "html" : "text"; + const sourceTitle = input.title ?? page.title; + const sourceSummary = input.summary ?? page.summary ?? ""; + const sourceContent = input.content ?? page.content ?? ""; + const titleRedaction = redactContent(sourceTitle, { format: "text" }); + const summaryRedaction = redactContent(sourceSummary, { format: "text" }); + const contentRedaction = redactContent(sourceContent, { format: scanFormat }); + const fieldChanges = (field, fieldLabel, redaction) => redaction.findings.map((finding) => ({ + field, + fieldLabel, + type: finding.type, + label: finding.label, + count: finding.occurrenceCount + })); + const changes = [ + ...fieldChanges("title", "\u6807\u9898", titleRedaction), + ...fieldChanges("summary", "\u6458\u8981", summaryRedaction), + ...fieldChanges("content", "\u6B63\u6587", contentRedaction) + ]; + const redactionsApplied = changes.reduce((total, change) => total + change.count, 0); + if (redactionsApplied === 0) { + throw pageError("\u672A\u53D1\u73B0\u9700\u8981\u4FEE\u590D\u7684\u98CE\u9669\u5185\u5BB9", "redaction_not_needed"); + } + const updated = await createVersion( + userId, + { + pageId, + expectedVersion: page.versionNo, + title: titleRedaction.content, + summary: summaryRedaction.content.trim() || sourceSummary, + content: contentRedaction.content, + templateId: page.templateId, + pageType: page.pageType, + contentFormat, + changeNote: `\u4E00\u952E\u4FEE\u590D v${page.versionNo + 1}` + }, + { + type: "generated", + snapshot: { + desensitized: true + } + } + ); + return { + page: updated, + originalScan: scanContent(`${sourceTitle} +${sourceSummary} +${sourceContent}`, { + format: scanFormat + }), + redactedScan: scanContent( + `${updated.title} +${updated.summary ?? ""} +${updated.content ?? ""}`, + { format: scanFormat } + ), + redactionsApplied, + changes + }; + }; + const localizePrivateResources = async (userId, pageId, input = {}) => { + const page = await getPage(userId, pageId); + if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) { + throw pageError("\u53EA\u80FD\u57FA\u4E8E\u5F53\u524D\u7248\u672C\u4FEE\u590D\u9875\u9762", "version_conflict", { + currentVersion: page.currentVersionId + }); + } + if (input.expectedVersion != null && asNumber4(input.expectedVersion) !== asNumber4(page.versionNo)) { + throw pageError("\u9875\u9762\u5DF2\u88AB\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5", "version_conflict", { + currentVersion: asNumber4(page.versionNo) + }); + } + if (page.contentFormat !== "html") { + throw pageError("\u8BE5\u9875\u9762\u4E0D\u652F\u6301\u8D44\u6E90\u672C\u5730\u5316\u4FEE\u590D", "unsupported_publish_fix"); + } + const sourceTitle = input.title ?? page.title; + const sourceSummary = input.summary ?? page.summary ?? ""; + const sourceContent = input.content ?? page.content ?? ""; + const matches = [...String(sourceContent).matchAll(PRIVATE_ASSET_URL_PATTERN)]; + if (matches.length === 0) { + throw pageError("\u672A\u53D1\u73B0\u53EF\u5B89\u5168\u4FEE\u590D\u7684\u79C1\u6709\u56FE\u7247\u5F15\u7528", "publish_fix_not_needed"); + } + const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; + const [assets] = await pool.query( + `SELECT a.id, a.mime_type, a.original_filename, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted'`, + [userId, assetIds] + ); + const byId = new Map(assets.map((asset) => [asset.id, asset])); + const replacements = /* @__PURE__ */ new Map(); + for (const assetId of assetIds) { + const asset = byId.get(assetId); + if (!asset) continue; + const buffer = await fs13.readFile(absoluteStoragePath(asset.storage_key)); + const mimeType = String(asset.mime_type || "application/octet-stream"); + const ext = extensionForMime(mimeType, asset.original_filename); + const dataUri = `data:${mimeType};base64,${buffer.toString("base64")}`; + replacements.set(assetId, { dataUri, ext }); + } + if (replacements.size === 0) { + throw pageError("\u79C1\u6709\u8D44\u6E90\u4E0D\u662F\u53EF\u5185\u8054\u7684\u56FE\u7247\uFF0C\u65E0\u6CD5\u81EA\u52A8\u4FEE\u590D", "unsupported_publish_fix"); + } + let changedCount = 0; + const updatedContent = String(sourceContent).replace( + PRIVATE_ASSET_URL_PATTERN, + (value, assetId) => { + const replacement = replacements.get(assetId); + if (!replacement) return value; + changedCount += 1; + return replacement.dataUri; + } + ); + if (updatedContent === sourceContent || changedCount === 0) { + throw pageError("\u672A\u53D1\u73B0\u53EF\u5B89\u5168\u4FEE\u590D\u7684\u79C1\u6709\u56FE\u7247\u5F15\u7528", "publish_fix_not_needed"); + } + const updated = await createVersion( + userId, + { + pageId, + expectedVersion: page.versionNo, + title: sourceTitle, + summary: sourceSummary, + content: updatedContent, + templateId: page.templateId, + pageType: page.pageType, + contentFormat: "html", + changeNote: `\u4E00\u952E\u53D1\u5E03\u4FEE\u590D v${page.versionNo + 1}` + }, + { + type: "generated", + snapshot: { + localized_private_resources: true + } + } + ); + return { + page: updated, + originalScan: scanContent(`${sourceTitle} +${sourceSummary} +${sourceContent}`, { + format: "html" + }), + redactedScan: scanContent( + `${updated.title} +${updated.summary ?? ""} +${updated.content ?? ""}`, + { format: "html" } + ), + redactionsApplied: changedCount, + changes: [ + { + field: "content", + fieldLabel: "\u6B63\u6587", + type: "private_resource_reference", + label: "\u79C1\u6709\u8D44\u6E90\u5F15\u7528", + count: changedCount + } + ] + }; + }; + const collectLinkedAssets = async (conn, userId, pageId) => { + const [versions] = await conn.query( + `SELECT pv.content_asset_id, pv.bundle_asset_id + FROM h5_page_versions pv + WHERE pv.page_id = ?`, + [pageId] + ); + const assetKind = /* @__PURE__ */ new Map(); + for (const version of versions) { + if (version.content_asset_id) assetKind.set(version.content_asset_id, "content"); + if (version.bundle_asset_id) assetKind.set(version.bundle_asset_id, "bundle"); + } + const assetIds = [...assetKind.keys()]; + if (assetIds.length === 0) { + return { assets: [], totalBytes: 0, assetIds: [] }; + } + const [assets] = await conn.query( + `SELECT id, display_name, size_bytes, status + FROM h5_assets + WHERE user_id = ? AND id IN (?) AND status <> 'deleted'`, + [userId, assetIds] + ); + const linkedAssets = assets.map((asset) => ({ + id: asset.id, + displayName: asset.display_name, + sizeBytes: asNumber4(asset.size_bytes), + kind: assetKind.get(asset.id) ?? "content" + })); + return { + assets: linkedAssets, + totalBytes: linkedAssets.reduce((sum, asset) => sum + asset.sizeBytes, 0), + assetIds: linkedAssets.map((asset) => asset.id) + }; + }; + const offlineOnlinePublications = async (conn, userId, pageId, now) => { + const [rows] = await conn.query( + `SELECT id, page_version_id, access_mode + FROM h5_publish_records + WHERE page_id = ? AND user_id = ? AND status = 'online' + FOR UPDATE`, + [pageId, userId] + ); + for (const publication of rows) { + await conn.query( + `UPDATE h5_publish_records + SET status = 'offline', offline_at = ?, updated_at = ? + WHERE id = ?`, + [now, now, publication.id] + ); + await conn.query( + `INSERT INTO h5_publication_events + (id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id, + access_mode, detail_json, created_at) + VALUES (?, ?, 'offlined', ?, ?, NULL, ?, ?, ?)`, + [ + idFactory(), + publication.id, + userId, + publication.page_version_id, + publication.access_mode, + JSON.stringify({ reason: "page_deleted" }), + now + ] + ); + } + return rows.length; + }; + const softDeleteLinkedAssets = async (conn, userId, spaceId, assetIds, now) => { + let freedBytes = 0; + let deletedAssetCount = 0; + for (const assetId of assetIds) { + const [rows] = await conn.query( + `SELECT id, size_bytes + FROM h5_assets + WHERE id = ? AND user_id = ? AND status <> 'deleted' + LIMIT 1 FOR UPDATE`, + [assetId, userId] + ); + const asset = rows[0]; + if (!asset) continue; + await conn.query( + `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [now, now, assetId, userId] + ); + freedBytes += asNumber4(asset.size_bytes); + deletedAssetCount += 1; + } + if (freedBytes > 0) { + await conn.query( + `UPDATE h5_user_spaces + SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [freedBytes, now, spaceId, userId] + ); + } + return { deletedAssetCount, freedBytes }; + }; + const getDeletePreview = async (userId, pageId) => { + const [rows] = await pool.query( + `SELECT p.id, p.title, p.source_asset_id, p.status + FROM h5_page_records p + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId] + ); + const page = rows[0]; + if (!page) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); + const conn = await pool.getConnection(); + try { + const [versionRows] = await conn.query( + `SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`, + [pageId] + ); + const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId); + const [publications] = await conn.query( + `SELECT id, status, public_url, access_mode, view_count + FROM h5_publish_records + WHERE page_id = ? AND user_id = ? + ORDER BY published_at DESC`, + [pageId, userId] + ); + const [agentJobs] = await conn.query( + `SELECT COUNT(*) AS job_count + FROM h5_agent_jobs + WHERE user_id = ? AND result_page_id = ?`, + [userId, pageId] + ); + const onlinePublication = publications.find((item) => item.status === "online") ?? null; + const offlinePublicationCount = publications.filter((item) => item.status !== "online").length; + const preview = { + page: { + id: page.id, + title: page.title, + status: page.status, + versionCount: asNumber4(versionRows[0]?.version_count) + }, + onlinePublication: onlinePublication ? { + id: onlinePublication.id, + publicUrl: onlinePublication.public_url, + accessMode: onlinePublication.access_mode, + viewCount: asNumber4(onlinePublication.view_count) + } : null, + offlinePublicationCount, + linkedAssets: assets, + linkedAssetBytes: totalBytes, + linkedAgentJobCount: asNumber4(agentJobs[0]?.job_count), + preservesSourceAsset: Boolean(page.source_asset_id) + }; + return { ...preview, summaryLines: buildPageDeleteSummary(preview) }; + } finally { + conn.release(); + } + }; + const deletePage = async (userId, pageId) => { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [pages] = await conn.query( + `SELECT id, space_id, title + FROM h5_page_records + WHERE id = ? AND user_id = ? AND status <> 'deleted' + LIMIT 1 FOR UPDATE`, + [pageId, userId] + ); + const page = pages[0]; + if (!page) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); + const now = Date.now(); + const { assetIds } = await collectLinkedAssets(conn, userId, pageId); + const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now); + const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets( + conn, + userId, + page.space_id, + assetIds, + now + ); + const [agentJobRows] = await conn.query( + `SELECT COUNT(*) AS job_count + FROM h5_agent_jobs + WHERE user_id = ? AND result_page_id = ?`, + [userId, pageId] + ); + const clearedAgentJobCount = asNumber4(agentJobRows[0]?.job_count); + await conn.query( + `UPDATE h5_agent_jobs + SET result_page_id = NULL, updated_at = ? + WHERE user_id = ? AND result_page_id = ?`, + [now, userId, pageId] + ); + await conn.query( + `UPDATE h5_page_records + SET status = 'deleted', deleted_at = ?, updated_at = ?, + current_publish_id = NULL, visibility = 'private' + WHERE id = ? AND user_id = ?`, + [now, now, pageId, userId] + ); + await conn.commit(); + return { + deleted: true, + pageId, + title: page.title, + offlinedPublicationCount, + deletedAssetCount, + freedBytes, + clearedAgentJobCount + }; + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const loadPageThumbnailContext = async (userId, pageId) => { + const [rows] = await pool.query( + `SELECT p.title, p.summary, p.page_type, pv.version_no, av.storage_key, pv.source_snapshot_json + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1 + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId] + ); + const row = rows[0]; + if (!row) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); + if (row.page_type !== "html") { + throw pageError("\u8BE5\u9875\u9762\u4E0D\u652F\u6301\u7F29\u7565\u56FE", "thumbnail_not_supported"); + } + const storagePath = await resolveReadableStoragePath(row.storage_key); + const content = await fs13.readFile(storagePath, "utf8"); + let snapshot = {}; + try { + snapshot = JSON.parse(row.source_snapshot_json ?? "{}"); + } catch { + snapshot = {}; + } + const workspaceHtmlRelativePath = snapshot.relative_path ?? null; + const workspacePublishDir = workspaceHtmlRelativePath ? await resolveWorkspacePublishDir(userId) : null; + return { + row, + content, + workspaceHtmlRelativePath, + workspacePublishDir, + thumbnailStorageKey: pageThumbnailKey(userId, pageId) + }; + }; + const syncWorkspaceThumbnail = async ({ + workspacePublishDir, + workspaceHtmlRelativePath, + html, + meta + }) => { + if (!workspacePublishDir || !workspaceHtmlRelativePath) return; + await ensureWorkspaceHtmlThumbnail( + workspacePublishDir, + workspaceHtmlRelativePath, + html, + { ...meta, force: true } + ).catch(() => { + }); + }; + const uploadThumbnail = async (userId, pageId, input = {}) => { + const imageBase64 = String(input.imageBase64 ?? "").trim(); + if (!imageBase64) throw pageError("\u8BF7\u4E0A\u4F20\u56FE\u7247", "thumbnail_image_required"); + const buffer = Buffer.from(imageBase64, "base64"); + const coverDataUri = bufferToImageDataUri(buffer); + if (!coverDataUri) throw pageError("\u56FE\u7247\u65E0\u6548\u6216\u8FC7\u5927", "thumbnail_image_invalid"); + const ctx = await loadPageThumbnailContext(userId, pageId); + const html = String(input.html ?? ctx.content); + const title = String(input.title ?? ctx.row.title ?? "").trim() || ctx.row.title; + const summary = String(input.summary ?? ctx.row.summary ?? "").trim(); + const signals = extractCoverSignals(html, { + title, + subtitle: summary || `\u8349\u7A3F v${asNumber4(ctx.row.version_no)}` + }); + const svg = buildFeedThumbnailSvg(signals, { coverDataUri }); + await writeThumbnail(storageRoot, ctx.thumbnailStorageKey, svg); + if (ctx.workspacePublishDir && ctx.workspaceHtmlRelativePath) { + await writeThumbnail( + ctx.workspacePublishDir, + workspaceThumbnailRelativePath(ctx.workspaceHtmlRelativePath), + svg + ); + } + return { updatedAt: Date.now() }; + }; + const regenerateThumbnail = async (userId, pageId, input = {}, options2 = {}) => { + const ctx = await loadPageThumbnailContext(userId, pageId); + let html = String(input.html ?? ctx.content); + const title = String(input.title ?? ctx.row.title ?? "").trim() || ctx.row.title; + const summary = String(input.summary ?? ctx.row.summary ?? "").trim(); + let updatedContent = null; + let metaOverrides = {}; + if (input.useAi) { + if (!options2.suggestCoverMeta) { + throw pageError("AI \u5C01\u9762\u751F\u6210\u672A\u542F\u7528", "cover_ai_unavailable"); + } + metaOverrides = await options2.suggestCoverMeta({ + title, + summary, + html, + instruction: input.instruction + }); + updatedContent = upsertMindspaceCoverMeta(html, metaOverrides); + html = updatedContent; + } + const meta = { + title, + subtitle: summary || `\u8349\u7A3F v${asNumber4(ctx.row.version_no)}`, + contentStorageKey: ctx.row.storage_key, + force: true, + ...metaOverrides + }; + await generateHtmlThumbnail(storageRoot, ctx.thumbnailStorageKey, html, meta); + await syncWorkspaceThumbnail({ + workspacePublishDir: ctx.workspacePublishDir, + workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath, + html, + meta + }); + return { + updatedAt: Date.now(), + content: updatedContent + }; + }; + return { + createFromChat: (userId, input, source) => createVersion(userId, input, { + type: "chat", + sessionId: source.sessionId, + messageId: source.messageId, + assetId: source.assetId ?? null, + snapshot: source.snapshot + }), + createFromAgent: (userId, input, source) => createVersion(userId, input, { + type: "agent", + assetId: source.assetId ?? null, + snapshot: { + job_id: source.jobId ?? null, + source_asset_ids: source.assetIds ?? [] + } + }), + createPage: (userId, input) => createVersion(userId, input, { type: "template" }), + updatePage: (userId, pageId, input) => createVersion(userId, { ...input, pageId }, { type: "generated" }), + localizePrivateResources, + redactPage, + createRedactedCopy: redactPage, + listPages, + findPageBySourceAsset, + getPage, + getDeletePreview, + deletePage, + listVersions, + renderPreview: async (userId, pageId) => { + const page = await getPage(userId, pageId); + const html = page.contentFormat === "html" ? renderHtmlPreview(page.content) : renderPreviewHtml(page); + return { html, contentFormat: page.contentFormat }; + }, + renderDraftPreview: async (userId, pageId, input = {}) => { + const page = await getPage(userId, pageId); + const title = String(input.title ?? page.title ?? "").trim() || page.title; + const summary = String(input.summary ?? page.summary ?? "").trim(); + const content = String(input.content ?? page.content ?? ""); + const templateId = input.templateId ?? page.templateId; + const html = page.contentFormat === "html" ? renderHtmlPreview(content) : renderPreviewHtml({ + title, + summary, + templateId, + versionNo: page.versionNo, + content + }); + return { html, contentFormat: page.contentFormat }; + }, + renderThumbnail: async (userId, pageId) => { + const ctx = await loadPageThumbnailContext(userId, pageId); + return ensurePageThumbnail({ + storageRoot, + pageThumbnailStorageKey: ctx.thumbnailStorageKey, + html: ctx.content, + meta: { + title: ctx.row.title, + subtitle: ctx.row.summary || `\u8349\u7A3F v${asNumber4(ctx.row.version_no)}`, + contentStorageKey: ctx.row.storage_key + }, + workspacePublishDir: ctx.workspacePublishDir, + workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath + }); + }, + uploadThumbnail, + regenerateThumbnail + }; +} +var pageInternals = { + escapeHtml: escapeHtml2, + normalizePageInput, + renderContent, + renderPreviewHtml, + renderHtmlPreview, + previewContentSecurityPolicy, + isHtmlPage, + renderPublicationHtml, + redactContent, + buildPageDeleteSummary +}; +function buildPageDeleteSummary(preview) { + const lines = [ + `\u9875\u9762\u300C${preview.page.title}\u300D\u53CA\u5176 ${preview.page.versionCount} \u4E2A\u7248\u672C` + ]; + if (preview.onlinePublication) { + lines.push(`\u5728\u7EBF\u516C\u5F00\u94FE\u63A5\u5C06\u7ACB\u5373\u4E0B\u7EBF\uFF1A${preview.onlinePublication.publicUrl}`); + } + if (preview.offlinePublicationCount > 0) { + lines.push(`${preview.offlinePublicationCount} \u6761\u5386\u53F2\u53D1\u5E03\u8BB0\u5F55\u5C06\u4FDD\u7559\u5BA1\u8BA1\u4FE1\u606F\uFF0C\u4F46\u4E0D\u518D\u53EF\u8BBF\u95EE`); + } + if (preview.linkedAssets.length > 0) { + lines.push( + `${preview.linkedAssets.length} \u4E2A\u9875\u9762\u5185\u5BB9\u8D44\u4EA7\u5C06\u88AB\u5220\u9664\uFF08\u7EA6 ${formatByteSize(preview.linkedAssetBytes)}\uFF09` + ); + } + if (preview.linkedAgentJobCount > 0) { + lines.push(`${preview.linkedAgentJobCount} \u4E2A Agent \u4EFB\u52A1\u7ED3\u679C\u5173\u8054\u5C06\u88AB\u6E05\u9664`); + } + if (preview.preservesSourceAsset) { + lines.push("\u539F\u59CB\u4E0A\u4F20\u8D44\u6599\u4E0D\u4F1A\u88AB\u5220\u9664"); + } + return lines; +} +function formatByteSize(bytes) { + const value = asNumber4(bytes); + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(value % 1024 === 0 ? 0 : 1)} KB`; + return `${(value / (1024 * 1024)).toFixed(value % (1024 * 1024) === 0 ? 0 : 1)} MB`; +} + +// mindspace-page-patch.mjs +function mergeMindSpacePagePatch(base, patch) { + return { + title: patch.title ?? base.title, + summary: patch.summary ?? base.summary, + content: patch.content ?? base.content + }; +} +function normalizeMindSpacePagePatchInput(input = {}) { + const patch = {}; + if (typeof input.title === "string") patch.title = input.title; + if (typeof input.summary === "string") patch.summary = input.summary; + if (typeof input.content === "string") patch.content = input.content; + return Object.keys(patch).length > 0 ? patch : null; +} +function buildMindSpacePageSaveInstructions(page, options = {}) { + if (!page?.id) return []; + const h5ApiBase = String(options.h5ApiBase ?? "").replace(/\/$/, ""); + const sessionId = String(options.sessionId ?? "").trim(); + const lines = [ + "\u3010\u9875\u9762\u5B9E\u65F6\u4FEE\u6539\uFF08\u786C\u6027\uFF09\u3011", + `\u7528\u6237\u8981\u6C42\u4FEE\u6539\u5F53\u524D\u9875\u9762\uFF08id: ${page.id}\uFF09\u65F6\uFF0C\u5FC5\u987B\u8BA9\u754C\u9762\u9884\u89C8\u7ACB\u523B\u66F4\u65B0\u3002\u4F18\u5148\u4F7F\u7528 API\uFF0C\u5176\u6B21\u4F7F\u7528\u8865\u4E01\u4EE3\u7801\u5757\u3002` + ]; + if (h5ApiBase && sessionId) { + lines.push( + "1. **\u4F18\u5148** \u6BCF\u6B21\u5B8C\u6210\u4E00\u5904\u4FEE\u6539\u540E\uFF0C\u7ACB\u5373\u7528 shell \u8C03\u7528\uFF08\u53EF\u591A\u6B21\u8C03\u7528\uFF0C\u6539\u4E00\u70B9\u8C03\u4E00\u6B21\uFF09\uFF1A", + "```bash", + `curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_page_patch' \\`, + " -H 'Content-Type: application/json' \\", + ` -d '{"session_id":"${sessionId}","page_id":"${page.id}","title":"\u4FEE\u6539\u540E\u7684\u6807\u9898"}'`, + "```", + "- \u53EA\u4F20\u5B9E\u9645\u53D8\u66F4\u5B57\u6BB5\uFF1A`title` / `summary` / `content`", + "- HTML \u9875\u9762\u6539\u6807\u9898\u6216\u6B63\u6587\u65F6\uFF0C`content` \u4F20\u5B8C\u6574 HTML", + "- \u7981\u6B62\u53EA\u6539\u5DE5\u4F5C\u533A\u91CC\u7684\u5176\u5B83 html \u6587\u4EF6\u800C\u4E0D\u8C03\u6B64 API" + ); + } + lines.push( + "2. **\u5907\u7528** \u82E5 curl \u5931\u8D25\uFF0C\u5728\u56DE\u590D\u672B\u5C3E\u8F93\u51FA\uFF1A", + "```mindspace-page-update", + JSON.stringify( + { + title: "\u4FEE\u6539\u540E\u7684\u6807\u9898\uFF08\u5982\u6709\u53D8\u66F4\uFF09", + summary: "\u4FEE\u6539\u540E\u7684\u6458\u8981\uFF08\u5982\u6709\u53D8\u66F4\uFF09", + content: page.contentFormat === "html" ? "" : "\u4FEE\u6539\u540E\u7684\u6B63\u6587\uFF08\u5982\u6709\u53D8\u66F4\uFF09" + }, + null, + 2 + ), + "```", + "- \u53EA\u586B\u5199\u5B9E\u9645\u53D8\u66F4\u7684\u5B57\u6BB5", + "- \u7981\u6B62\u53EA\u53E3\u5934\u8BF4\u300C\u5DF2\u4FEE\u6539\u300D\u800C\u4E0D\u8C03\u7528 API \u6216\u8F93\u51FA\u8865\u4E01\u5757", + "" + ); + return lines; +} + +// mindspace-page-live-edit.mjs +function createPageLiveEditService({ pageService, resolveUserIdForAgentSession }) { + const sessionBindings = /* @__PURE__ */ new Map(); + const liveRevisions = /* @__PURE__ */ new Map(); + const bumpRevision = (pageId) => { + liveRevisions.set(pageId, (liveRevisions.get(pageId) ?? 0) + 1); + }; + return { + bindSession({ userId, sessionId, pageId, parentSessionId = null }) { + if (!userId || !sessionId || !pageId) { + throw Object.assign(new Error("\u7F3A\u5C11\u7ED1\u5B9A\u53C2\u6570"), { code: "invalid_request" }); + } + sessionBindings.set(String(sessionId), { + userId: String(userId), + pageId: String(pageId), + parentSessionId: parentSessionId ? String(parentSessionId) : null, + boundAt: Date.now() + }); + return { + sessionId: String(sessionId), + pageId: String(pageId), + ...parentSessionId ? { parentSessionId: String(parentSessionId) } : {} + }; + }, + unbindSession(sessionId) { + sessionBindings.delete(String(sessionId)); + }, + getBinding(sessionId) { + return sessionBindings.get(String(sessionId)) ?? null; + }, + getLiveRevision(pageId) { + return liveRevisions.get(String(pageId)) ?? 0; + }, + async applyAgentPatch(input) { + const sessionId = String(input?.sessionId ?? input?.session_id ?? "").trim(); + const pageId = String(input?.pageId ?? input?.page_id ?? "").trim(); + if (!sessionId || !pageId) { + throw Object.assign(new Error("\u7F3A\u5C11 session_id \u6216 page_id"), { code: "invalid_request" }); + } + const userId = await resolveUserIdForAgentSession(sessionId); + if (!userId) { + throw Object.assign(new Error("\u65E0\u6548\u7684 Agent \u4F1A\u8BDD"), { code: "forbidden" }); + } + const binding = sessionBindings.get(sessionId); + if (binding && binding.pageId !== pageId) { + throw Object.assign(new Error("\u5F53\u524D\u4F1A\u8BDD\u672A\u7ED1\u5B9A\u8BE5\u9875\u9762"), { code: "page_binding_mismatch" }); + } + if (binding && binding.userId !== userId) { + throw Object.assign(new Error("\u4F1A\u8BDD\u4E0E\u7528\u6237\u4E0D\u5339\u914D"), { code: "forbidden" }); + } + const patch = normalizeMindSpacePagePatchInput(input); + if (!patch) { + throw Object.assign(new Error("\u7F3A\u5C11 title / summary / content \u53D8\u66F4"), { code: "invalid_request" }); + } + const page = await pageService.getPage(userId, pageId); + const merged = mergeMindSpacePagePatch( + { + title: page.title, + summary: page.summary ?? "", + content: page.content ?? "" + }, + patch + ); + const updated = await pageService.updatePage(userId, pageId, { + expectedVersion: input?.expectedVersion ?? input?.expected_version ?? page.versionNo, + title: merged.title, + summary: merged.summary, + content: merged.content, + templateId: page.templateId, + changeNote: String(input?.changeNote ?? input?.change_note ?? "Agent \u5BF9\u8BDD\u4FEE\u6539").slice(0, 255) + }); + bumpRevision(pageId); + return { + page: updated, + liveRevision: liveRevisions.get(pageId) ?? 0 + }; + }, + async getRevisionSnapshot(userId, pageId) { + const page = await pageService.getPage(userId, pageId); + return { + pageId: page.id, + versionNo: page.versionNo, + updatedAt: page.updatedAt, + liveRevision: liveRevisions.get(pageId) ?? 0 + }; + } + }; +} + +// mindspace-page-edit-session.mjs +import { Agent as Agent2, fetch as undiciFetch2 } from "undici"; + +// mindspace-chat-context.mjs +function buildPageEditSubAgentInstructions(context) { + const pageTitle = context.page?.title ?? "\u5F53\u524D\u9875\u9762"; + const lines = [ + "\u3010\u9875\u9762\u7F16\u8F91\u5B50 Agent\uFF08\u786C\u6027\uFF09\u3011", + `- \u4F60\u662F\u4E13\u6CE8\u4FEE\u6539\u9875\u9762\u300C${pageTitle}\u300D\u7684\u5B50 Agent\uFF0C\u7528\u6237\u6B63\u5728\u5168\u5C4F\u9884\u89C8\u4E2D\u7F16\u8F91\u3002`, + "- \u53EA\u505A\u9875\u9762\u5185\u5BB9/\u6807\u9898/\u6837\u5F0F\u4FEE\u6539\uFF0C\u4E0D\u8981\u8DD1\u9898\uFF0C\u4E0D\u8981 delegate / load_skill / \u6539\u5176\u5B83\u6587\u4EF6\u3002", + "- \u6BCF\u6B21\u5B8C\u6210\u4E00\u5904\u4FEE\u6539\u540E\u5FC5\u987B\u8BA9\u9884\u89C8\u7ACB\u523B\u66F4\u65B0\uFF08\u89C1\u4E0B\u65B9\u9875\u9762\u5B9E\u65F6\u4FEE\u6539\u8BF4\u660E\uFF09\u3002" + ]; + if (context.parentAgentSessionId) { + lines.push(`- \u7236\u5BF9\u8BDD session\uFF1A${context.parentAgentSessionId}\uFF08\u9000\u51FA\u9884\u89C8\u540E\u6458\u8981\u4F1A\u5408\u5E76\u56DE\u7236\u5BF9\u8BDD\u8BB0\u5FC6\uFF09\u3002`); + } + lines.push(""); + return lines.join("\n"); +} + +// mindspace-page-edit-session.mjs +var insecureDispatcher2 = new Agent2({ + connect: { rejectUnauthorized: false } +}); +function isHttpsTarget2(target) { + return target.startsWith("https://"); +} +function createApiFetch(apiTarget, apiSecret) { + return async (pathname, init = {}) => { + const url = new URL(pathname, apiTarget); + const headers = { + ...init.headers ?? {}, + "X-Secret-Key": apiSecret + }; + if (init.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + return undiciFetch2(url, { + ...init, + headers, + dispatcher: isHttpsTarget2(apiTarget) ? insecureDispatcher2 : void 0 + }); + }; +} +async function readJson2(upstream) { + const text = await upstream.text(); + if (!upstream.ok) { + throw Object.assign(new Error(text || `upstream ${upstream.status}`), { + code: "worker_unavailable" + }); + } + return text ? JSON.parse(text) : null; +} +function buildPageEditSeedContent({ page, parentSessionId, h5ApiBase, sessionId }) { + const context = { + spaceId: "mindspace", + spaceName: "MindSpace", + view: "page", + route: `/mindspace/page/${page.id}/preview`, + pageEditMode: true, + parentAgentSessionId: parentSessionId, + agentSessionId: sessionId, + h5ApiBase, + page: { + id: page.id, + title: page.title, + summary: page.summary ?? "", + status: page.status, + versionNo: page.versionNo, + templateId: page.templateId, + contentFormat: page.contentFormat ?? "html" + } + }; + const lines = [ + ...buildPageEditSubAgentInstructions(context).split("\n"), + "[MindSpace \u9875\u9762\u7F16\u8F91\u5B50\u4F1A\u8BDD]", + `- \u9875\u9762\uFF1A${page.title}\uFF08id: ${page.id}\uFF09`, + `- \u5185\u5BB9\u683C\u5F0F\uFF1A${page.contentFormat ?? "html"}`, + `- \u5F53\u524D\u7248\u672C\uFF1Av${page.versionNo ?? 1}`, + ...buildMindSpacePageSaveInstructions( + { + id: page.id, + contentFormat: page.contentFormat ?? "html" + }, + { sessionId, h5ApiBase } + ), + "\u8BF7\u7528\u4E00\u53E5\u8BDD\u786E\u8BA4\u4F60\u5DF2\u51C6\u5907\u597D\u4FEE\u6539\u6B64\u9875\u9762\uFF0C\u5E76\u7B49\u5F85\u7528\u6237\u6307\u4EE4\u3002" + ]; + return lines.join("\n"); +} +function buildPageEditSessionConstraints(pageTitle) { + return [ + "\u3010\u9875\u9762\u7F16\u8F91\u5B50 Agent \u6C99\u7BB1\u3011", + `- \u5F53\u524D\u4EFB\u52A1\uFF1A\u4EC5\u4FEE\u6539 MindSpace \u9875\u9762\u300C${pageTitle}\u300D`, + "- \u7981\u6B62\u8BFB\u5199\u5DE5\u4F5C\u533A\u5176\u5B83\u6587\u4EF6\uFF1B\u7981\u6B62 delegate / load_skill / \u53D1\u5E03\u65B0\u9875\u9762", + "- \u6539\u9875\u9762\u4F18\u5148 curl \u8C03\u7528 mindspace_page_patch\uFF1B\u82E5\u65E0 shell \u6743\u9650\u5219\u5728\u56DE\u590D\u672B\u5C3E\u8F93\u51FA ```mindspace-page-update``` \u5757", + "- \u6BCF\u6B21\u6539\u52A8\u540E\u7ACB\u5373\u89E6\u53D1\u9884\u89C8\u66F4\u65B0\uFF0C\u4E0D\u8981\u53EA\u53E3\u5934\u8BF4\u300C\u5DF2\u4FEE\u6539\u300D" + ].join("\n"); +} +function createPageEditSessionService({ + apiTarget, + apiSecret, + userAuth: userAuth2, + pageService, + pageLiveEdit, + llmProviderService: llmProviderService2 +}) { + const apiFetch2 = createApiFetch(apiTarget, apiSecret); + const applySessionLlmProvider = async (sessionId) => { + if (!llmProviderService2 || !sessionId) return null; + try { + return await llmProviderService2.applyBestProviderForSession(sessionId); + } catch { + return null; + } + }; + return { + async forkSession({ userId, pageId, parentSessionId, h5ApiBase = null }) { + if (!userId || !pageId || !parentSessionId) { + throw Object.assign(new Error("\u7F3A\u5C11 fork \u53C2\u6570"), { code: "invalid_request" }); + } + const ownsParent = await userAuth2.ownsSession(userId, parentSessionId); + if (!ownsParent) { + throw Object.assign(new Error("\u65E0\u6743\u4F7F\u7528\u7236\u4F1A\u8BDD"), { code: "forbidden" }); + } + const page = await pageService.getPage(userId, pageId); + if (page.contentFormat !== "html") { + throw Object.assign(new Error("\u4EC5 HTML \u9875\u9762\u652F\u6301\u7F16\u8F91\u5B50\u4F1A\u8BDD"), { code: "invalid_request" }); + } + const gate = await userAuth2.canUseChat(userId); + if (!gate.ok) { + throw Object.assign(new Error(gate.message || "\u5F53\u524D\u7528\u6237\u65E0\u6CD5\u4F7F\u7528 Agent"), { code: "forbidden" }); + } + const workingDir = await userAuth2.resolveWorkingDir(userId); + const basePolicy = await userAuth2.getAgentSessionPolicy(userId); + const sessionPolicy = buildPageEditAgentPolicy(basePolicy); + const publishLayout = await userAuth2.getUserPublishLayout(userId); + const startSession = await readJson2( + await apiFetch2("/agent/start", { + method: "POST", + body: JSON.stringify({ + working_dir: workingDir, + enable_context_memory: sessionPolicy.enableContextMemory, + ...sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {} + }) + }) + ); + const sessionId = startSession?.id; + if (!sessionId) { + throw Object.assign(new Error("Agent \u4F1A\u8BDD\u542F\u52A8\u5931\u8D25"), { code: "worker_unavailable" }); + } + await userAuth2.registerAgentSession(userId, sessionId); + if (sessionPolicy.gooseMode) { + await readJson2( + await apiFetch2("/agent/update_session", { + method: "POST", + body: JSON.stringify({ + session_id: sessionId, + goose_mode: sessionPolicy.gooseMode + }) + }) + ); + } + const sandboxConstraints = [ + publishLayout?.constraints ?? "", + buildPageEditSessionConstraints(page.title) + ].filter(Boolean).join("\n\n"); + await reconcileAgentSession(apiFetch2, sessionId, { + workingDir, + sessionPolicy, + sandboxConstraints, + userContext: publishLayout ? { + userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + }); + await applySessionLlmProvider(sessionId); + await pageLiveEdit.bindSession({ + userId, + sessionId, + pageId, + parentSessionId + }); + const seed = buildPageEditSeedContent({ + page, + parentSessionId, + h5ApiBase, + sessionId + }); + await apiFetch2("/agent/harness_remember", { + method: "POST", + body: JSON.stringify({ + sessionId, + title: `\u9875\u9762\u7F16\u8F91 \xB7 ${page.title}`, + content: seed + }) + }); + await apiFetch2("/agent/harness_bootstrap", { + method: "POST", + body: JSON.stringify({ sessionId, force: true }) + }); + return { + sessionId, + pageId, + parentSessionId + }; + }, + async closeSession({ userId, sessionId, pageId, parentSessionId, summary = "" }) { + if (!userId || !sessionId || !pageId) { + throw Object.assign(new Error("\u7F3A\u5C11 close \u53C2\u6570"), { code: "invalid_request" }); + } + const owns = await userAuth2.ownsSession(userId, sessionId); + if (!owns) { + throw Object.assign(new Error("\u65E0\u6743\u5173\u95ED\u8BE5\u5B50\u4F1A\u8BDD"), { code: "forbidden" }); + } + const binding = pageLiveEdit.getBinding(sessionId); + if (binding && binding.pageId !== String(pageId)) { + throw Object.assign(new Error("\u5B50\u4F1A\u8BDD\u672A\u7ED1\u5B9A\u8BE5\u9875\u9762"), { code: "page_binding_mismatch" }); + } + pageLiveEdit.unbindSession(sessionId); + const trimmedSummary = String(summary ?? "").trim(); + const parentId = String(parentSessionId ?? binding?.parentSessionId ?? "").trim(); + if (trimmedSummary && parentId) { + const ownsParent = await userAuth2.ownsSession(userId, parentId); + if (ownsParent) { + const page = await pageService.getPage(userId, pageId).catch(() => null); + await apiFetch2("/agent/harness_remember", { + method: "POST", + body: JSON.stringify({ + sessionId: parentId, + title: `\u9875\u9762\u7F16\u8F91\u6458\u8981 \xB7 ${page?.title ?? pageId}`, + content: trimmedSummary + }) + }); + } + } + return { sessionId, pageId, merged: Boolean(trimmedSummary && parentId) }; + } + }; +} + +// mindspace-cover-ai.mjs +import { Agent as Agent4, fetch as undiciFetch4 } from "undici"; +import { jsonrepair } from "jsonrepair"; + +// llm-providers.mjs +import crypto11 from "node:crypto"; +import fs14 from "node:fs"; +import os from "node:os"; +import path15 from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { Agent as Agent3, fetch as undiciFetch3 } from "undici"; +var CUSTOM_PROVIDER_ID = "__custom__"; +var LLM_PROVIDER_CATALOG = [ + { + id: CUSTOM_PROVIDER_ID, + label: "\u81EA\u5B9A\u4E49 OpenAI \u517C\u5BB9", + kind: "custom", + apiKeyEnv: null, + defaultModel: "", + models: [] + }, + { + id: "custom_deepseek", + label: "DeepSeek", + kind: "builtin", + apiKeyEnv: "DEEPSEEK_API_KEY", + defaultModel: "deepseek-chat", + models: ["deepseek-chat", "deepseek-reasoner"] + }, + { + id: "openai", + label: "OpenAI", + kind: "builtin", + apiKeyEnv: "OPENAI_API_KEY", + defaultModel: "gpt-4o", + models: ["gpt-4o", "gpt-4o-mini"] + }, + { + id: "openrouter", + label: "OpenRouter", + kind: "builtin", + apiKeyEnv: "OPENROUTER_API_KEY", + defaultModel: "anthropic/claude-sonnet-4", + models: ["anthropic/claude-sonnet-4", "openai/gpt-4o"] + }, + { + id: "anthropic", + label: "Anthropic", + kind: "builtin", + apiKeyEnv: "ANTHROPIC_API_KEY", + defaultModel: "claude-sonnet-4-20250514", + models: ["claude-sonnet-4-20250514", "claude-3-5-haiku-20241022"] + }, + { + id: "custom_qwen", + label: "Qwen VL (\u901A\u4E49\u5343\u95EE)", + kind: "builtin", + apiKeyEnv: null, + // stored as custom provider; apiUrl is injected at createKey time + apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + defaultModel: "qwen-vl-max", + models: ["qwen-vl-max", "qwen-vl-max-latest", "qwen-vl-plus", "qwen-plus", "qwen-turbo"] + } +]; +var LLM_EXECUTOR_CATALOG = [ + { + id: "goose", + label: "Goose", + description: "\u7B56\u7565\u5224\u65AD\u3001\u5206\u6790\u3001\u603B\u7ED3\u548C\u8F7B\u91CF\u4EFB\u52A1\u7F16\u6392", + purposes: ["default"] + }, + { + id: "aider", + label: "Aider", + description: "\u5C0F\u8303\u56F4\u4EE3\u7801\u4FEE\u6539\u548C\u8865\u4E01\u5F0F\u4FEE\u590D", + purposes: ["default"] + }, + { + id: "openhands", + label: "OpenHands", + description: "\u590D\u6742\u4ED3\u5E93\u4EFB\u52A1\u3001\u591A\u6587\u4EF6\u6539\u9020\u548C\u547D\u4EE4\u6267\u884C", + purposes: ["default"] + } +]; +var catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item])); +var executorById = Object.fromEntries(LLM_EXECUTOR_CATALOG.map((item) => [item.id, item])); +var BUILTIN_PROVIDER_TEST_URLS = { + custom_deepseek: process.env.DEEPSEEK_API_BASE_URL ?? "https://api.deepseek.com/v1", + openai: process.env.OPENAI_API_BASE_URL ?? "https://api.openai.com/v1", + openrouter: process.env.OPENROUTER_API_BASE_URL ?? "https://openrouter.ai/api/v1" +}; +var insecureDispatcher3 = new Agent3({ + connect: { rejectUnauthorized: false } +}); +function resolveEncryptionKey(explicitKey) { + const raw = explicitKey ?? process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"; + return crypto11.createHash("sha256").update(raw).digest(); +} +function encryptSecret(plaintext, encryptionKey) { + const key = resolveEncryptionKey(encryptionKey); + const iv = crypto11.randomBytes(12); + const cipher = crypto11.createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + ciphertext: encrypted.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64") + }; +} +function decryptSecret({ ciphertext, iv, tag }, encryptionKey) { + const key = resolveEncryptionKey(encryptionKey); + const decipher = crypto11.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64")); + decipher.setAuthTag(Buffer.from(tag, "base64")); + const plain = Buffer.concat([ + decipher.update(Buffer.from(ciphertext, "base64")), + decipher.final() + ]); + return plain.toString("utf8"); +} +function maskApiKey(apiKey) { + if (!apiKey) return ""; + if (apiKey.length <= 8) return "*".repeat(apiKey.length); + const head = apiKey.slice(0, 4); + const tail = apiKey.slice(-4); + return `${head}${"*".repeat(Math.max(apiKey.length - 8, 4))}${tail}`; +} +function parseModelList(raw) { + if (Array.isArray(raw)) { + return [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))]; + } + return [ + ...new Set( + String(raw ?? "").split(/[\n,]/).map((item) => item.trim()).filter(Boolean) + ) + ]; +} +function normalizeApiUrl(raw) { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, ""); + return `http://${trimmed.replace(/\/+$/, "")}`; +} +function resolveApiBaseUrl(apiUrl) { + const normalized = normalizeApiUrl(apiUrl); + if (!normalized) return ""; + if (normalized.endsWith("/chat/completions")) { + return normalized.slice(0, -"/chat/completions".length).replace(/\/+$/, ""); + } + return normalized.replace(/\/+$/, ""); +} +function resolveChatCompletionsUrl(apiUrl) { + const normalized = normalizeApiUrl(apiUrl); + if (!normalized) return ""; + if (normalized.endsWith("/chat/completions")) return normalized; + if (normalized.endsWith("/v1")) return `${normalized}/chat/completions`; + return `${normalized}/chat/completions`; +} +var RELAY_BOOTSTRAP = { + name: process.env.H5_RELAY_BOOTSTRAP_NAME ?? "Relay Buyer Ollama", + apiUrl: process.env.H5_RELAY_BOOTSTRAP_URL ?? "http://127.0.0.1:18300/relay/buyer/v1/chat/completions", + apiKey: process.env.H5_RELAY_BOOTSTRAP_API_KEY ?? "UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo", + models: parseModelList(process.env.H5_RELAY_BOOTSTRAP_MODELS ?? "qwen2.5:3b"), + defaultModel: process.env.H5_RELAY_BOOTSTRAP_MODEL ?? "qwen2.5:3b", + relayProvider: process.env.H5_RELAY_BOOTSTRAP_PROVIDER ?? "ollama" +}; +var LOCAL_LLM_FALLBACK = { + enabled: process.env.H5_LOCAL_LLM_FALLBACK !== "0", + providerId: process.env.H5_LOCAL_LLM_PROVIDER_ID ?? "custom_local_ollama_7b", + displayName: process.env.H5_LOCAL_LLM_NAME ?? "Local Ollama 7B", + apiUrl: process.env.H5_LOCAL_LLM_URL ?? "http://127.0.0.1:11434/v1/chat/completions", + apiKey: process.env.H5_LOCAL_LLM_API_KEY ?? "ollama", + model: process.env.H5_LOCAL_LLM_MODEL ?? "qwen2.5:3b" +}; +var cachedLocalFallbackProviderId = null; +async function testRelayConnection({ apiUrl, apiKey, model, relayProvider }, fetchImpl = undiciFetch3) { + const url = resolveChatCompletionsUrl(apiUrl); + if (!url) return { ok: false, message: "API \u5730\u5740\u65E0\u6548" }; + if (!apiKey) return { ok: false, message: "\u7F3A\u5C11 API Key" }; + if (!model) return { ok: false, message: "\u7F3A\u5C11\u6A21\u578B" }; + const started = Date.now(); + const body = { + model, + messages: [{ role: "user", content: "Hello" }], + stream: false, + ...relayProvider ? { provider: relayProvider } : {} + }; + const upstream = await fetchImpl(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify(body), + dispatcher: url.startsWith("https://") ? insecureDispatcher3 : void 0 + }); + const latencyMs = Date.now() - started; + const text = await upstream.text().catch(() => ""); + if (!upstream.ok) { + let detail = text.slice(0, 500) || "(\u7A7A\u54CD\u5E94)"; + if (detail === "{}") { + detail = "\u7A7A JSON \u54CD\u5E94"; + } + const statusHint = upstream.status === 401 ? "Bearer Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" : upstream.status === 404 ? "\u5730\u5740\u4E0D\u5B58\u5728\uFF0C\u8BF7\u68C0\u67E5 API URL" : upstream.status === 400 ? "\u8BF7\u6C42\u53C2\u6570\u88AB\u62D2\u7EDD\uFF0C\u8BF7\u68C0\u67E5 model / provider" : `HTTP ${upstream.status}`; + return { + ok: false, + latencyMs, + message: `Relay ${upstream.status} ${statusHint}\uFF1A${detail}` + }; + } + let data; + try { + data = JSON.parse(text); + } catch { + return { ok: false, latencyMs, message: "\u54CD\u5E94\u4E0D\u662F JSON" }; + } + const reply = data?.choices?.[0]?.message?.content ?? data?.message?.content ?? data?.output ?? null; + return { + ok: true, + latencyMs, + model, + reply: reply ? String(reply).slice(0, 300) : "(\u8054\u901A\u6210\u529F\uFF0C\u65E0\u6587\u672C\u5185\u5BB9)" + }; +} +async function testLocalLlmConnection(fetchImpl = undiciFetch3) { + if (!LOCAL_LLM_FALLBACK.enabled) { + return { ok: false, message: "\u672C\u5730 LLM fallback \u5DF2\u7981\u7528" }; + } + return testRelayConnection( + { + apiUrl: LOCAL_LLM_FALLBACK.apiUrl, + apiKey: LOCAL_LLM_FALLBACK.apiKey, + model: LOCAL_LLM_FALLBACK.model, + relayProvider: null + }, + fetchImpl + ); +} +function parseModelsJson(raw) { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parseModelList(parsed) : []; + } catch { + return []; + } +} +function rowToPublic(row, catalogItem, apiKeyForMask) { + const providerKind = row.provider_kind ?? "builtin"; + const models = providerKind === "custom" ? parseModelsJson(row.models_json) : catalogItem?.models ?? []; + return { + id: row.id, + providerId: row.provider_id, + providerKind, + providerLabel: providerKind === "custom" ? row.name : catalogItem?.label ?? row.provider_id, + name: row.name, + defaultModel: row.default_model, + models, + apiUrl: row.api_url ?? null, + basePath: row.base_path ?? null, + engine: row.engine ?? "openai", + relayProvider: row.relay_provider ?? null, + goosedProviderId: row.goosed_provider_id ?? null, + status: row.status, + isSelected: Boolean(row.is_selected), + isVisionSelected: Boolean(row.is_vision_selected), + apiKeyMasked: maskApiKey(apiKeyForMask), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function profileFromRow(row, decryptRow) { + const providerKind = row.provider_kind ?? "builtin"; + const models = providerKind === "custom" ? parseModelsJson(row.models_json) : [row.default_model]; + return { + providerKind, + providerId: row.provider_id, + goosedProviderId: row.goosed_provider_id ?? null, + name: row.name, + defaultModel: row.default_model, + apiKey: decryptRow(row), + apiUrl: row.api_url ?? null, + basePath: row.base_path ?? null, + engine: row.engine ?? "openai", + relayProvider: row.relay_provider ?? null, + models + }; +} +function normalizeExecutor(raw) { + const executor = String(raw ?? "").trim().toLowerCase(); + return executorById[executor] ? executor : null; +} +function normalizePurpose(raw) { + return String(raw ?? "default").trim() || "default"; +} +function rowToExecutorBinding(row, keyPublic = null) { + const executor = normalizeExecutor(row.executor); + const meta = executorById[executor] ?? null; + return { + id: row.id, + executor, + executorLabel: meta?.label ?? row.executor, + executorDescription: meta?.description ?? "", + purpose: row.purpose ?? "default", + providerKeyId: row.provider_key_id ?? null, + providerName: keyPublic?.name ?? null, + providerLabel: keyPublic?.providerLabel ?? null, + providerId: keyPublic?.providerId ?? null, + model: row.model ?? "", + enabled: Boolean(row.enabled), + availableModels: keyPublic?.models ?? [], + createdAt: row.created_at ? Number(row.created_at) : null, + updatedAt: row.updated_at ? Number(row.updated_at) : null + }; +} +function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) { + const apiKeyValue = includeSecret ? profile.apiKey : "[hidden]"; + const baseUrl = resolveApiBaseUrl(profile.apiUrl); + const providerId = profile.providerId; + const common = { + TKMIND_EXECUTOR: executor, + TKMIND_EXECUTOR_PROVIDER: providerId, + TKMIND_EXECUTOR_MODEL: model + }; + if (baseUrl) common.TKMIND_EXECUTOR_API_BASE = baseUrl; + if (includeSecret) common.TKMIND_EXECUTOR_API_KEY = profile.apiKey; + if (executor === "aider") { + const env = { + ...common, + AIDER_MODEL: model + }; + if (providerId === "anthropic") { + env.AIDER_ANTHROPIC_API_KEY = apiKeyValue; + env.ANTHROPIC_API_KEY = apiKeyValue; + } else { + env.AIDER_OPENAI_API_KEY = apiKeyValue; + env.OPENAI_API_KEY = apiKeyValue; + if (providerId === "openrouter") env.OPENROUTER_API_KEY = apiKeyValue; + if (providerId === "custom_deepseek") env.DEEPSEEK_API_KEY = apiKeyValue; + } + if (baseUrl) { + env.AIDER_OPENAI_API_BASE = baseUrl; + env.OPENAI_API_BASE = baseUrl; + } + return env; + } + if (executor === "openhands") { + return { + ...common, + LLM_MODEL: model, + LLM_API_KEY: apiKeyValue, + ...baseUrl ? { LLM_BASE_URL: baseUrl } : {} + }; + } + return { + ...common, + GOOSE_PROVIDER: profile.providerKind === "custom" ? profile.goosedProviderId ?? profile.providerId : profile.providerId, + GOOSE_MODEL: model, + TKMIND_PROVIDER: profile.providerKind === "custom" ? profile.goosedProviderId ?? profile.providerId : profile.providerId, + TKMIND_MODEL: model + }; +} +function launchLogDir() { + return path15.join(os.tmpdir(), "memindadm-launches"); +} +function normalizeLaunchMode(mode) { + const normalized = String(mode ?? "headless").trim().toLowerCase(); + return normalized === "serve" ? "serve" : "headless"; +} +function commandExists(command) { + const probe = spawnSync("sh", ["-lc", `command -v ${JSON.stringify(command).slice(1, -1)}`], { + stdio: "ignore" + }); + return probe.status === 0; +} +function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) { + if (!plan?.ok) { + return plan; + } + if (!plan.command) { + return { + ok: false, + executor: plan.executor ?? null, + message: "\u542F\u52A8\u547D\u4EE4\u7F3A\u5931" + }; + } + if (!commandExists(plan.command)) { + return { + ok: false, + executor: plan.executor ?? null, + message: `${plan.command} \u672A\u5B89\u88C5\u6216\u4E0D\u5728 PATH \u4E2D` + }; + } + fs14.mkdirSync(logDir, { recursive: true }); + const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); + const logFile = path15.join( + logDir, + `${plan.executor ?? "executor"}-${stamp}-${crypto11.randomUUID().slice(0, 8)}.log` + ); + const fd = fs14.openSync(logFile, "a"); + try { + const child = spawn(plan.command, plan.args ?? [], { + cwd: plan.cwd ?? process.cwd(), + env: { + ...process.env, + ...plan.env ?? {} + }, + detached: true, + stdio: ["ignore", fd, fd] + }); + child.unref(); + return { + ok: true, + executor: plan.executor ?? null, + pid: child.pid ?? null, + command: plan.command, + args: plan.args ?? [], + cwd: plan.cwd ?? process.cwd(), + logFile, + notes: plan.notes ?? [] + }; + } catch (err) { + return { + ok: false, + executor: plan.executor ?? null, + message: err instanceof Error ? err.message : "\u542F\u52A8\u5931\u8D25", + logFile + }; + } finally { + try { + fs14.closeSync(fd); + } catch { + } + } +} +function isProcessAlive(pid) { + if (!pid || Number.isNaN(Number(pid))) return false; + try { + process.kill(Number(pid), 0); + return true; + } catch (err) { + if (err && typeof err === "object" && "code" in err && err.code === "EPERM") return true; + return false; + } +} +function launchStateKey(executor, purpose = "default") { + return `${normalizeExecutor(executor)}:${normalizePurpose(purpose)}`; +} +function buildExecutorLaunchPlan(runtime, options = {}) { + if (!runtime?.ok) { + return { + ok: false, + executor: runtime?.executor ?? null, + message: runtime?.message ?? "\u8FD0\u884C\u914D\u7F6E\u672A\u5C31\u7EEA" + }; + } + const mode = normalizeLaunchMode(options.mode ?? "headless"); + const cwd = String(options.cwd ?? process.cwd()); + const instruction = String(options.instruction ?? "").trim(); + const env = runtime.env ?? {}; + if (runtime.executor === "goose") { + return { + ok: false, + executor: "goose", + message: "Goose \u4F5C\u4E3A\u73B0\u6709\u670D\u52A1\u5165\u53E3\uFF0C\u4E0D\u901A\u8FC7\u672C\u65B9\u6CD5\u542F\u52A8" + }; + } + if (runtime.executor === "aider") { + const hasInstruction = Boolean(instruction); + return { + ok: true, + executor: "aider", + cwd, + command: "aider", + args: [ + "--model", + runtime.model, + ...hasInstruction ? ["--message", instruction] : [], + "--yes-always" + ], + env, + notes: [ + "Aider \u901A\u8FC7\u7EDF\u4E00\u6A21\u578B\u4E2D\u5FC3\u63D0\u4F9B\u7684 API Key / Base URL \u8FD0\u884C\u3002", + "\u5B9E\u9645\u542F\u52A8\u65F6\u5EFA\u8BAE\u5728\u4ED3\u5E93\u76EE\u5F55\u4E2D\u6267\u884C\u3002" + ] + }; + } + if (runtime.executor === "openhands") { + if (mode === "headless" && !instruction) { + return { + ok: false, + executor: "openhands", + message: "OpenHands headless \u6A21\u5F0F\u9700\u8981 instruction" + }; + } + const args = mode === "serve" ? ["serve", "--mount-cwd"] : ["--headless", "--json", "--override-with-envs", "--task", instruction]; + return { + ok: true, + executor: "openhands", + cwd, + command: "openhands", + args, + env, + notes: [ + mode === "serve" ? "OpenHands GUI Server \u4F9D\u8D56 Docker\uFF0C\u9002\u5408\u4EBA\u5DE5\u4EA4\u4E92\u8C03\u8BD5\u3002" : "Headless \u6A21\u5F0F\u9002\u5408\u540E\u7EED\u548C\u540E\u53F0\u8C03\u5EA6\u5BF9\u63A5\u3002" + ] + }; + } + return { + ok: false, + executor: runtime.executor, + message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" + }; +} +async function goosedApiFetch(apiTarget, apiSecret, pathname, init = {}, fetchImpl = undiciFetch3) { + const url = new URL(pathname, apiTarget); + const headers = { + ...init.headers ?? {}, + "X-Secret-Key": apiSecret + }; + if (init.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + const dispatcher = apiTarget.startsWith("https://") ? insecureDispatcher3 : void 0; + return fetchImpl(url, { ...init, headers, dispatcher }); +} +async function writeGoosedConfig(apiTarget, apiSecret, key, value, isSecret, fetchImpl) { + const upstream = await goosedApiFetch( + apiTarget, + apiSecret, + "/config/upsert", + { + method: "POST", + body: JSON.stringify({ key, value, is_secret: isSecret }) + }, + fetchImpl + ); + if (upstream.ok) return { ok: true, skipped: false }; + if (upstream.status === 404) return { ok: false, skipped: true }; + const text = await upstream.text().catch(() => ""); + throw new Error(`\u540C\u6B65 TKMind Agent \u914D\u7F6E ${key} \u5931\u8D25: ${text || upstream.status}`); +} +async function ensureLocalFallbackProviderOnGoosed(apiTarget, apiSecret, fetchImpl = undiciFetch3) { + if (!LOCAL_LLM_FALLBACK.enabled) { + throw new Error("\u672C\u5730 LLM fallback \u5DF2\u7981\u7528"); + } + const profile = { + providerKind: "custom", + name: LOCAL_LLM_FALLBACK.displayName, + apiUrl: LOCAL_LLM_FALLBACK.apiUrl, + apiKey: LOCAL_LLM_FALLBACK.apiKey, + models: [LOCAL_LLM_FALLBACK.model], + defaultModel: LOCAL_LLM_FALLBACK.model, + goosedProviderId: cachedLocalFallbackProviderId, + engine: "openai" + }; + const goosedProviderId = await upsertCustomProviderOnGoosed( + apiTarget, + apiSecret, + profile, + fetchImpl + ); + cachedLocalFallbackProviderId = goosedProviderId; + return goosedProviderId; +} +async function updateSessionProvider(apiFetchImpl, sessionId, provider, model) { + const upstream = await apiFetchImpl("/agent/update_provider", { + method: "POST", + body: JSON.stringify({ + session_id: sessionId, + provider, + model + }) + }); + if (!upstream.ok) { + const text = await upstream.text().catch(() => ""); + throw new Error(text || `\u5207\u6362\u4F1A\u8BDD Provider \u5931\u8D25: ${upstream.status}`); + } +} +async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetchImpl) { + const headers = profile.relayProvider ? { "X-Provider": profile.relayProvider } : void 0; + const body = { + engine: profile.engine || "openai", + display_name: profile.name, + api_url: profile.apiUrl, + api_key: profile.apiKey, + models: profile.models, + supports_streaming: true, + requires_auth: true, + ...profile.basePath ? { base_path: profile.basePath } : {}, + ...headers ? { headers } : {} + }; + if (profile.goosedProviderId) { + const upstream2 = await goosedApiFetch( + apiTarget, + apiSecret, + `/config/custom-providers/${encodeURIComponent(profile.goosedProviderId)}`, + { method: "PUT", body: JSON.stringify(body) }, + fetchImpl + ); + if (!upstream2.ok) { + const text = await upstream2.text().catch(() => ""); + throw new Error(`\u66F4\u65B0 TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream2.status}`); + } + return profile.goosedProviderId; + } + const upstream = await goosedApiFetch( + apiTarget, + apiSecret, + "/config/custom-providers", + { method: "POST", body: JSON.stringify(body) }, + fetchImpl + ); + if (!upstream.ok) { + const text = await upstream.text().catch(() => ""); + throw new Error(`\u521B\u5EFA TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream.status}`); + } + const data = await upstream.json(); + return data.provider_name; +} +async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProviderId, fetchImpl) { + if (!goosedProviderId) return; + const upstream = await goosedApiFetch( + apiTarget, + apiSecret, + `/config/custom-providers/${encodeURIComponent(goosedProviderId)}`, + { method: "DELETE" }, + fetchImpl + ); + if (!upstream.ok && upstream.status !== 404) { + const text = await upstream.text().catch(() => ""); + throw new Error(`\u5220\u9664 TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream.status}`); + } +} +async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) { + const catalogItem = catalogById[profile.providerId]; + if (!catalogItem?.apiKeyEnv) { + throw new Error(`\u672A\u77E5\u5185\u7F6E provider: ${profile.providerId}`); + } + await writeGoosedConfig( + apiTarget, + apiSecret, + catalogItem.apiKeyEnv, + profile.apiKey, + true, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "GOOSE_PROVIDER", + profile.providerId, + false, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "GOOSE_MODEL", + profile.defaultModel, + false, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "TKMIND_PROVIDER", + profile.providerId, + false, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "TKMIND_MODEL", + profile.defaultModel, + false, + fetchImpl + ); +} +async function syncProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl = undiciFetch3) { + if (profile.providerKind === "custom") { + const goosedProviderId = await upsertCustomProviderOnGoosed( + apiTarget, + apiSecret, + profile, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "GOOSE_PROVIDER", + goosedProviderId, + false, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "GOOSE_MODEL", + profile.defaultModel, + false, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "TKMIND_PROVIDER", + goosedProviderId, + false, + fetchImpl + ); + await writeGoosedConfig( + apiTarget, + apiSecret, + "TKMIND_MODEL", + profile.defaultModel, + false, + fetchImpl + ); + return goosedProviderId; + } + await syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl); + return profile.providerId; +} +function isCustomPayload(payload) { + return payload?.providerId === CUSTOM_PROVIDER_ID || payload?.providerKind === "custom"; +} +function validateCustomPayload(payload) { + const name = String(payload?.name ?? "").trim(); + const apiKey = String(payload?.apiKey ?? "").trim(); + const apiUrl = normalizeApiUrl(payload?.apiUrl); + const models = parseModelList(payload?.models); + const defaultModel = String(payload?.defaultModel ?? "").trim() || models[0] || ""; + if (!name) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" }; + if (!apiUrl) return { ok: false, message: "\u8BF7\u586B\u5199 API \u5730\u5740" }; + if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key / Bearer Token" }; + if (models.length === 0) return { ok: false, message: "\u8BF7\u81F3\u5C11\u586B\u5199\u4E00\u4E2A\u6A21\u578B" }; + if (!models.includes(defaultModel)) { + return { ok: false, message: "\u9ED8\u8BA4\u6A21\u578B\u5FC5\u987B\u5728\u6A21\u578B\u5217\u8868\u4E2D" }; + } + return { + ok: true, + value: { + name, + apiKey, + apiUrl, + models, + defaultModel, + basePath: String(payload?.basePath ?? "").trim() || null, + engine: String(payload?.engine ?? "openai").trim() || "openai", + relayProvider: String(payload?.relayProvider ?? "").trim() || null + } + }; +} +function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, apiFetchImpl = undiciFetch3 } = {}) { + const launchStates = /* @__PURE__ */ new Map(); + function catalogItem(providerId) { + return catalogById[providerId] ?? null; + } + function decryptRow(row) { + return decryptSecret( + { + ciphertext: row.api_key_ciphertext, + iv: row.api_key_iv, + tag: row.api_key_tag + }, + encryptionKey + ); + } + async function getRowById(id) { + const [rows] = await pool.query("SELECT * FROM h5_llm_provider_keys WHERE id = ? LIMIT 1", [ + id + ]); + return rows[0] ?? null; + } + async function getSelectedRow() { + const [rows] = await pool.query( + "SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = ? LIMIT 1", + ["active"] + ); + return rows[0] ?? null; + } + async function getExecutorBindingRow(executor, purpose = "default") { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) return null; + const [rows] = await pool.query( + "SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1", + [normalizedExecutor, normalizePurpose(purpose)] + ); + return rows[0] ?? null; + } + async function clearSelected() { + await pool.query("UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?", [ + Date.now() + ]); + } + async function getVisionRow() { + const [rows] = await pool.query( + "SELECT * FROM h5_llm_provider_keys WHERE is_vision_selected = 1 AND status = ? LIMIT 1", + ["active"] + ); + return rows[0] ?? null; + } + async function clearVisionSelected() { + await pool.query("UPDATE h5_llm_provider_keys SET is_vision_selected = 0, updated_at = ?", [ + Date.now() + ]); + } + let visionKeyConfiguredCache = null; + const syncRowCache = /* @__PURE__ */ new Map(); + const SYNC_ROW_CACHE_TTL_MS = 3e4; + async function syncRow(row, fetchImpl = apiFetchImpl) { + const cacheKey = `${row.id}:${row.updated_at}`; + const cached = syncRowCache.get(cacheKey); + if (cached && Date.now() - cached.syncedAt < SYNC_ROW_CACHE_TTL_MS && fetchImpl === apiFetchImpl) { + return cached.goosedProviderId; + } + const profile = profileFromRow(row, decryptRow); + const goosedProviderId = await syncProfileToGoosed( + apiTarget, + apiSecret, + profile, + fetchImpl + ); + if (profile.providerKind === "custom" && goosedProviderId !== row.goosed_provider_id) { + await pool.query( + "UPDATE h5_llm_provider_keys SET goosed_provider_id = ?, provider_id = ?, updated_at = ? WHERE id = ?", + [goosedProviderId, goosedProviderId, Date.now(), row.id] + ); + } + if (fetchImpl === apiFetchImpl) { + syncRowCache.set(cacheKey, { goosedProviderId, syncedAt: Date.now() }); + } + return goosedProviderId; + } + async function syncRowWithModel(row, model, fetchImpl = apiFetchImpl) { + const originalModel = row.default_model; + row.default_model = model || row.default_model; + try { + return await syncRow(row, fetchImpl); + } finally { + row.default_model = originalModel; + } + } + async function resolveExecutorProvider(executor, purpose = "default", fetchImpl = apiFetchImpl) { + const binding = await getExecutorBindingRow(executor, purpose); + if (!binding?.enabled) { + return { ok: false, message: `${executorById[executor]?.label ?? executor} \u672A\u542F\u7528\u6A21\u578B\u7ED1\u5B9A` }; + } + if (!binding.provider_key_id) { + return { ok: false, message: `${executorById[executor]?.label ?? executor} \u672A\u7ED1\u5B9A Provider` }; + } + const row = await getRowById(binding.provider_key_id); + if (!row || row.status !== "active") { + return { ok: false, message: "\u7ED1\u5B9A\u7684 Provider \u4E0D\u5B58\u5728\u6216\u5DF2\u7981\u7528" }; + } + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (!publicRow.models.includes(binding.model)) { + return { ok: false, message: "\u7ED1\u5B9A\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" }; + } + try { + const goosedProviderId = await syncRowWithModel(row, binding.model, fetchImpl); + const profile = profileFromRow(row, decryptRow); + const providerId = profile.providerKind === "custom" ? goosedProviderId ?? profile.goosedProviderId ?? profile.providerId : profile.providerId; + return { + ok: true, + providerId, + model: binding.model, + source: "executor_binding", + executor, + purpose: binding.purpose + }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : "\u540C\u6B65\u6267\u884C\u5668\u6A21\u578B\u914D\u7F6E\u5931\u8D25" + }; + } + } + async function resolveExecutorRuntimeConfig(executor, { purpose = "default", includeSecret = false } = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" }; + const binding = await getExecutorBindingRow(normalizedExecutor, purpose); + if (!binding?.enabled) { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} \u672A\u542F\u7528\u6A21\u578B\u7ED1\u5B9A` + }; + } + if (!binding.provider_key_id) { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} \u672A\u7ED1\u5B9A Provider` + }; + } + const row = await getRowById(binding.provider_key_id); + if (!row || row.status !== "active") { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: "\u7ED1\u5B9A\u7684 Provider \u4E0D\u5B58\u5728\u6216\u5DF2\u7981\u7528" + }; + } + const keyPublic = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (!keyPublic.models.includes(binding.model)) { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: "\u7ED1\u5B9A\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" + }; + } + const profile = profileFromRow(row, decryptRow); + const runtimeProfile = includeSecret ? profile : { ...profile, apiKey: "" }; + return { + ok: true, + executor: normalizedExecutor, + executorLabel: executorById[normalizedExecutor]?.label ?? normalizedExecutor, + purpose: binding.purpose, + providerKeyId: row.id, + providerName: row.name, + providerKind: profile.providerKind, + providerId: profile.providerId, + providerLabel: keyPublic.providerLabel, + goosedProviderId: profile.goosedProviderId, + model: binding.model, + apiUrl: profile.apiUrl, + apiBaseUrl: resolveApiBaseUrl(profile.apiUrl), + apiKeyMasked: keyPublic.apiKeyMasked, + env: executorEnvForProfile(normalizedExecutor, runtimeProfile, binding.model, { + includeSecret + }) + }; + } + function getLaunchState(executor, purpose = "default") { + const key = launchStateKey(executor, purpose); + const current = launchStates.get(key); + if (!current) return null; + if (current.pid && !isProcessAlive(current.pid)) { + current.running = false; + current.stoppedAt = current.stoppedAt ?? Date.now(); + current.exitReason = current.exitReason ?? "not_running"; + launchStates.set(key, current); + } + return current; + } + function setLaunchState(executor, purpose, state) { + const key = launchStateKey(executor, purpose); + launchStates.set(key, { + ...state, + executor: normalizeExecutor(executor), + purpose: normalizePurpose(purpose) + }); + return launchStates.get(key); + } + function stateToPublic(state) { + if (!state) return null; + return { + executor: state.executor, + purpose: state.purpose, + pid: state.pid ?? null, + running: Boolean(state.running), + command: state.command ?? null, + args: state.args ?? [], + cwd: state.cwd ?? null, + logFile: state.logFile ?? null, + startedAt: state.startedAt ?? null, + stoppedAt: state.stoppedAt ?? null, + exitReason: state.exitReason ?? null, + mode: state.mode ?? null, + instruction: state.instruction ?? null + }; + } + async function stopLaunchState(state, { force = false } = {}) { + if (!state?.pid) { + return { ok: false, message: "\u672A\u627E\u5230\u8FD0\u884C\u4E2D\u7684\u6267\u884C\u5668\u8FDB\u7A0B" }; + } + if (!isProcessAlive(state.pid)) { + state.running = false; + state.stoppedAt = state.stoppedAt ?? Date.now(); + state.exitReason = state.exitReason ?? "not_running"; + return { ok: true, stopped: true, state: stateToPublic(state) }; + } + try { + process.kill(state.pid, "SIGTERM"); + if (!force) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + if (force || isProcessAlive(state.pid)) { + try { + process.kill(state.pid, "SIGKILL"); + } catch { + } + } + state.running = false; + state.stoppedAt = Date.now(); + state.exitReason = force ? "force_kill" : "sigterm"; + return { ok: true, stopped: true, state: stateToPublic(state) }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : "\u505C\u6B62\u5931\u8D25" + }; + } + } + async function testSelectedRow(row) { + const profile = profileFromRow(row, decryptRow); + if (profile.providerKind === "custom") { + try { + return await testRelayConnection( + { + apiUrl: profile.apiUrl, + apiKey: profile.apiKey, + model: profile.defaultModel, + relayProvider: profile.relayProvider + }, + apiFetchImpl + ); + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : String(err) + }; + } + } + return { ok: true, model: profile.defaultModel }; + } + async function resolveSelectedProvider(fetchImpl = apiFetchImpl) { + const row = await getSelectedRow(); + if (!row) { + return { ok: false, message: "\u8BF7\u5148\u542F\u7528 LLM \u914D\u7F6E" }; + } + const profile = profileFromRow(row, decryptRow); + const selectedTest = await testSelectedRow(row); + if (!selectedTest.ok) { + return { + ok: false, + message: selectedTest.message ?? "\u5F53\u524D\u9009\u4E2D\u7684 LLM \u4E0D\u53EF\u7528" + }; + } + try { + const goosedProviderId = await syncRow(row, fetchImpl); + const providerId = profile.providerKind === "custom" ? goosedProviderId ?? profile.goosedProviderId ?? profile.providerId : profile.providerId; + return { + ok: true, + providerId, + model: profile.defaultModel, + source: "selected" + }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : "\u540C\u6B65 LLM \u914D\u7F6E\u5931\u8D25" + }; + } + } + async function resolveLocalFallbackProvider(fetchImpl = apiFetchImpl) { + let localTest; + try { + localTest = await testLocalLlmConnection(apiFetchImpl); + } catch (err) { + localTest = { + ok: false, + message: err instanceof Error ? err.message : String(err) + }; + } + if (!localTest.ok) { + return { + ok: false, + message: localTest.message ?? "\u672C\u5730 LLM \u4E0D\u53EF\u7528" + }; + } + const providerId = await ensureLocalFallbackProviderOnGoosed( + apiTarget, + apiSecret, + fetchImpl + ); + return { + ok: true, + providerId, + model: LOCAL_LLM_FALLBACK.model, + source: "local_fallback" + }; + } + const goosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, apiFetchImpl); + return { + catalog: LLM_PROVIDER_CATALOG, + executorCatalog: LLM_EXECUTOR_CATALOG, + async listKeys() { + const [rows] = await pool.query( + "SELECT * FROM h5_llm_provider_keys ORDER BY is_selected DESC, updated_at DESC" + ); + return rows.map( + (row) => rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)) + ); + }, + async listExecutorBindings() { + const keys = await this.listKeys(); + const keyMap = new Map(keys.map((key) => [key.id, key])); + const [rows] = await pool.query( + 'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands"), purpose' + ); + const rowMap = new Map( + rows.map((row) => [`${row.executor}:${row.purpose}`, row]) + ); + return LLM_EXECUTOR_CATALOG.map((executor) => { + const row = rowMap.get(`${executor.id}:default`); + if (!row) { + return { + id: null, + executor: executor.id, + executorLabel: executor.label, + executorDescription: executor.description, + purpose: "default", + providerKeyId: null, + providerName: null, + providerLabel: null, + providerId: null, + model: "", + enabled: false, + availableModels: [], + createdAt: null, + updatedAt: null + }; + } + return rowToExecutorBinding(row, keyMap.get(row.provider_key_id) ?? null); + }); + }, + async setExecutorBinding(executor, payload = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" }; + const purpose = normalizePurpose(payload.purpose); + const enabled = payload.enabled !== false; + const keyId = String(payload.keyId ?? payload.providerKeyId ?? "").trim() || null; + const model = String(payload.model ?? "").trim(); + if (enabled && !keyId) return { ok: false, message: "\u8BF7\u9009\u62E9 Provider" }; + if (enabled && !model) return { ok: false, message: "\u8BF7\u9009\u62E9\u6A21\u578B" }; + let keyPublic = null; + if (keyId) { + const row2 = await getRowById(keyId); + if (!row2) return { ok: false, message: "Provider \u4E0D\u5B58\u5728" }; + if (row2.status !== "active") return { ok: false, message: "Provider \u5DF2\u7981\u7528" }; + keyPublic = rowToPublic(row2, catalogItem(row2.provider_id), decryptRow(row2)); + if (model && !keyPublic.models.includes(model)) { + return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u8BE5 Provider \u652F\u6301\u5217\u8868\u4E2D" }; + } + } + const existing = await getExecutorBindingRow(normalizedExecutor, purpose); + const now = Date.now(); + if (existing) { + await pool.query( + `UPDATE h5_llm_executor_bindings + SET provider_key_id = ?, model = ?, enabled = ?, updated_at = ? + WHERE id = ?`, + [keyId, model, enabled ? 1 : 0, now, existing.id] + ); + } else { + await pool.query( + `INSERT INTO h5_llm_executor_bindings + (id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [crypto11.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now] + ); + } + const row = await getExecutorBindingRow(normalizedExecutor, purpose); + return { ok: true, binding: rowToExecutorBinding(row, keyPublic) }; + }, + async getExecutorRuntimeConfig(executor, options = {}) { + return resolveExecutorRuntimeConfig(executor, options); + }, + async listExecutorRuntimeConfigs(options = {}) { + const configs = await Promise.all( + LLM_EXECUTOR_CATALOG.map( + (executor) => resolveExecutorRuntimeConfig(executor.id, options) + ) + ); + return configs; + }, + async getExecutorLaunchPlan(executor, options = {}) { + const runtime = await resolveExecutorRuntimeConfig(executor, { + purpose: options.purpose ?? "default", + includeSecret: options.includeSecret ?? false + }); + return buildExecutorLaunchPlan(runtime, options); + }, + async listExecutorLaunchPlans(options = {}) { + const runtimes = await this.listExecutorRuntimeConfigs({ + purpose: options.purpose ?? "default", + includeSecret: options.includeSecret ?? false + }); + return runtimes.map((runtime) => buildExecutorLaunchPlan(runtime, options)); + }, + async listExecutorLaunchStates(options = {}) { + const purpose = normalizePurpose(options.purpose ?? "default"); + return LLM_EXECUTOR_CATALOG.map((item) => stateToPublic(getLaunchState(item.id, purpose))); + }, + async getExecutorLaunchState(executor, options = {}) { + const purpose = normalizePurpose(options.purpose ?? "default"); + return stateToPublic(getLaunchState(executor, purpose)); + }, + async launchExecutor(executor, options = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) { + return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" }; + } + const purpose = normalizePurpose(options.purpose ?? "default"); + const mode = normalizeLaunchMode(options.mode ?? (normalizedExecutor === "openhands" ? "serve" : "headless")); + const instruction = String(options.instruction ?? "").trim(); + const currentState = getLaunchState(normalizedExecutor, purpose); + if (currentState?.running) { + return { + ok: true, + executor: normalizedExecutor, + purpose, + running: true, + reused: true, + launch: stateToPublic(currentState) + }; + } + if (normalizedExecutor === "goose") { + return { + ok: false, + executor: "goose", + message: "Goose \u4F5C\u4E3A\u73B0\u6709\u670D\u52A1\u5165\u53E3\uFF0C\u4E0D\u901A\u8FC7\u540E\u53F0\u76F4\u63A5\u542F\u52A8" + }; + } + if (normalizedExecutor === "aider" && !instruction) { + return { + ok: false, + executor: "aider", + message: "Aider \u542F\u52A8\u9700\u8981 instruction" + }; + } + if (normalizedExecutor === "openhands" && mode === "headless" && !instruction) { + return { + ok: false, + executor: "openhands", + message: "OpenHands headless \u542F\u52A8\u9700\u8981 instruction" + }; + } + const runtime = await resolveExecutorRuntimeConfig(normalizedExecutor, { + purpose, + includeSecret: true + }); + const plan = buildExecutorLaunchPlan(runtime, { + purpose, + mode, + cwd: options.cwd, + instruction, + includeSecret: true + }); + if (!plan.ok) { + return plan; + } + const launch = spawnDetachedExecutor(plan, { logDir: options.logDir }); + if (!launch.ok) return launch; + const state = setLaunchState(normalizedExecutor, purpose, { + pid: launch.pid ?? null, + running: true, + command: launch.command, + args: launch.args ?? [], + cwd: launch.cwd ?? plan.cwd, + logFile: launch.logFile, + startedAt: Date.now(), + mode, + instruction: instruction || null + }); + return { + ...launch, + purpose, + running: true, + launch: stateToPublic(state) + }; + }, + async stopExecutor(executor, options = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) { + return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" }; + } + const purpose = normalizePurpose(options.purpose ?? "default"); + const state = getLaunchState(normalizedExecutor, purpose); + if (!state) { + return { ok: false, executor: normalizedExecutor, purpose, message: "\u672A\u627E\u5230\u6267\u884C\u8BB0\u5F55" }; + } + const result = await stopLaunchState(state, { force: Boolean(options.force) }); + if (!result.ok) return { ok: false, executor: normalizedExecutor, purpose, message: result.message }; + setLaunchState(normalizedExecutor, purpose, state); + return { + ok: true, + executor: normalizedExecutor, + purpose, + launch: result.state + }; + }, + async restartExecutor(executor, options = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) { + return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" }; + } + const purpose = normalizePurpose(options.purpose ?? "default"); + const state = getLaunchState(normalizedExecutor, purpose); + if (!state) { + return { ok: false, executor: normalizedExecutor, purpose, message: "\u672A\u627E\u5230\u53EF\u91CD\u542F\u7684\u6267\u884C\u8BB0\u5F55" }; + } + await stopLaunchState(state, { force: true }); + const launchOptions = { + purpose, + mode: options.mode ?? state.mode ?? (normalizedExecutor === "openhands" ? "serve" : "headless"), + cwd: options.cwd ?? state.cwd ?? process.cwd(), + instruction: options.instruction ?? state.instruction ?? "", + logDir: options.logDir + }; + return this.launchExecutor(normalizedExecutor, launchOptions); + }, + async createKey(payload) { + const custom = isCustomPayload(payload); + let providerKind = "builtin"; + let providerId = String(payload?.providerId ?? "").trim(); + let insertName = String(payload?.name ?? "").trim(); + let apiKey = String(payload?.apiKey ?? "").trim(); + let defaultModel = String(payload?.defaultModel ?? "").trim(); + let apiUrl = null; + let basePath = null; + let engine = "openai"; + let relayProvider = null; + let modelsJson = null; + let meta = catalogItem(providerId); + if (custom) { + const validated = validateCustomPayload(payload); + if (!validated.ok) return validated; + providerKind = "custom"; + providerId = CUSTOM_PROVIDER_ID; + meta = catalogById[CUSTOM_PROVIDER_ID]; + insertName = validated.value.name; + apiKey = validated.value.apiKey; + apiUrl = validated.value.apiUrl; + basePath = validated.value.basePath; + engine = validated.value.engine; + relayProvider = validated.value.relayProvider; + defaultModel = validated.value.defaultModel; + modelsJson = JSON.stringify(validated.value.models); + } else if (providerId === "custom_qwen") { + if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" }; + if (!insertName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" }; + if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key" }; + providerKind = "custom"; + apiUrl = meta.apiUrl ?? "https://dashscope.aliyuncs.com/compatible-mode/v1"; + const qwenModels = meta.models; + defaultModel = defaultModel || meta.defaultModel || qwenModels[0]; + if (!qwenModels.includes(defaultModel)) defaultModel = qwenModels[0]; + modelsJson = JSON.stringify(qwenModels); + } else { + if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" }; + if (!insertName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" }; + if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key" }; + defaultModel = defaultModel || meta.defaultModel; + if (!meta.models.includes(defaultModel)) { + return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6A21\u578B" }; + } + } + const [existing] = await pool.query( + "SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1", + [insertName] + ); + if (existing.length > 0) return { ok: false, message: "\u914D\u7F6E\u540D\u79F0\u5DF2\u5B58\u5728" }; + const [countRows] = await pool.query("SELECT COUNT(*) AS total FROM h5_llm_provider_keys"); + const shouldSelect = Number(countRows[0]?.total ?? 0) === 0; + const encrypted = encryptSecret(apiKey, encryptionKey); + const now = Date.now(); + const id = crypto11.randomUUID(); + if (shouldSelect) await clearSelected(); + await pool.query( + `INSERT INTO h5_llm_provider_keys + (id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id, engine, relay_provider, + name, api_key_ciphertext, api_key_iv, api_key_tag, default_model, status, is_selected, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`, + [ + id, + providerId, + providerKind, + apiUrl, + basePath, + modelsJson, + engine, + relayProvider, + insertName, + encrypted.ciphertext, + encrypted.iv, + encrypted.tag, + defaultModel, + shouldSelect ? 1 : 0, + now, + now + ] + ); + let row = await getRowById(id); + if (shouldSelect) { + await syncRow(row); + row = await getRowById(id); + } + return { ok: true, key: rowToPublic(row, meta, apiKey) }; + }, + async updateKey(id, payload) { + const row = await getRowById(id); + if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" }; + const providerKind = row.provider_kind ?? "builtin"; + const nextName = payload?.name !== void 0 ? String(payload.name).trim() : row.name; + const nextApiKey = payload?.apiKey !== void 0 ? String(payload.apiKey).trim() : null; + const nextStatus = payload?.status === "disabled" || payload?.status === "active" ? payload.status : row.status; + if (!nextName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" }; + const [existing] = await pool.query( + "SELECT id FROM h5_llm_provider_keys WHERE name = ? AND id <> ? LIMIT 1", + [nextName, id] + ); + if (existing.length > 0) return { ok: false, message: "\u914D\u7F6E\u540D\u79F0\u5DF2\u5B58\u5728" }; + if (row.is_selected && nextStatus === "disabled") { + return { ok: false, message: "\u5F53\u524D\u542F\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u7981\u7528\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u5176\u4ED6\u914D\u7F6E" }; + } + let nextModel = row.default_model; + let nextApiUrl = row.api_url; + let nextBasePath = row.base_path; + let nextEngine = row.engine ?? "openai"; + let nextRelayProvider = row.relay_provider; + let nextModelsJson = row.models_json; + if (providerKind === "custom") { + const models = payload?.models !== void 0 ? parseModelList(payload.models) : parseModelsJson(row.models_json); + nextModel = payload?.defaultModel !== void 0 ? String(payload.defaultModel).trim() : row.default_model; + nextApiUrl = payload?.apiUrl !== void 0 ? normalizeApiUrl(payload.apiUrl) : row.api_url; + nextBasePath = payload?.basePath !== void 0 ? String(payload.basePath).trim() || null : row.base_path; + nextEngine = payload?.engine !== void 0 ? String(payload.engine).trim() || "openai" : row.engine; + nextRelayProvider = payload?.relayProvider !== void 0 ? String(payload.relayProvider).trim() || null : row.relay_provider; + if (models.length === 0) return { ok: false, message: "\u8BF7\u81F3\u5C11\u4FDD\u7559\u4E00\u4E2A\u6A21\u578B" }; + if (!models.includes(nextModel)) { + return { ok: false, message: "\u9ED8\u8BA4\u6A21\u578B\u5FC5\u987B\u5728\u6A21\u578B\u5217\u8868\u4E2D" }; + } + if (!nextApiUrl) return { ok: false, message: "\u8BF7\u586B\u5199 API \u5730\u5740" }; + nextModelsJson = JSON.stringify(models); + } else { + const meta = catalogItem(row.provider_id); + if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" }; + nextModel = payload?.defaultModel !== void 0 ? String(payload.defaultModel).trim() : row.default_model; + if (!meta.models.includes(nextModel)) { + return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6A21\u578B" }; + } + } + const encrypted = nextApiKey ? encryptSecret(nextApiKey, encryptionKey) : { + ciphertext: row.api_key_ciphertext, + iv: row.api_key_iv, + tag: row.api_key_tag + }; + const now = Date.now(); + await pool.query( + `UPDATE h5_llm_provider_keys + SET name = ?, api_url = ?, base_path = ?, models_json = ?, engine = ?, relay_provider = ?, + api_key_ciphertext = ?, api_key_iv = ?, api_key_tag = ?, + default_model = ?, status = ?, updated_at = ? + WHERE id = ?`, + [ + nextName, + nextApiUrl, + nextBasePath, + nextModelsJson, + nextEngine, + nextRelayProvider, + encrypted.ciphertext, + encrypted.iv, + encrypted.tag, + nextModel, + nextStatus, + now, + id + ] + ); + if (row.is_selected) { + await syncRow(await getRowById(id)); + } + const updated = await getRowById(id); + return { + ok: true, + key: rowToPublic(updated, catalogItem(updated.provider_id), nextApiKey || decryptRow(row)) + }; + }, + async selectKey(id) { + const row = await getRowById(id); + if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" }; + if (row.status !== "active") { + return { ok: false, message: "\u5DF2\u7981\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u542F\u7528" }; + } + await clearSelected(); + const now = Date.now(); + await pool.query( + "UPDATE h5_llm_provider_keys SET is_selected = 1, updated_at = ? WHERE id = ?", + [now, id] + ); + await syncRow(row); + const updated = await getRowById(id); + return { + ok: true, + key: rowToPublic(updated, catalogItem(updated.provider_id), decryptRow(updated)) + }; + }, + async deleteKey(id) { + const row = await getRowById(id); + if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" }; + if (row.is_selected) { + return { ok: false, message: "\u5F53\u524D\u542F\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u5220\u9664\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u5176\u4ED6\u914D\u7F6E" }; + } + if ((row.provider_kind ?? "builtin") === "custom" && row.goosed_provider_id) { + await removeCustomProviderOnGoosed( + apiTarget, + apiSecret, + row.goosed_provider_id, + apiFetchImpl + ); + } + await pool.query("DELETE FROM h5_llm_provider_keys WHERE id = ?", [id]); + return { ok: true }; + }, + async syncSelectedToGoosed() { + let resolved = await resolveExecutorProvider("goose"); + if (!resolved.ok) { + resolved = await resolveSelectedProvider(); + } + if (!resolved.ok) { + return { ok: false, synced: false, message: resolved.message }; + } + return { + ok: true, + synced: true, + source: resolved.source, + providerId: resolved.providerId, + model: resolved.model + }; + }, + async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) { + let resolved = await resolveExecutorProvider("goose", "default", fetchImpl); + if (!resolved.ok) { + resolved = await resolveSelectedProvider(fetchImpl); + } + if (!resolved.ok) { + return resolved; + } + const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl); + await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model); + return resolved; + }, + /** Switch session to local Ollama (credits exhausted or relay 500 payload limit). */ + async applyLocalFallbackForSession(sessionId, fetchImpl = apiFetchImpl) { + const resolved = await resolveLocalFallbackProvider(fetchImpl); + if (!resolved.ok) { + return resolved; + } + const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl); + await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model); + return resolved; + }, + async hasVisionKey() { + if (visionKeyConfiguredCache !== null) return visionKeyConfiguredCache; + const row = await getVisionRow(); + visionKeyConfiguredCache = row !== null; + return visionKeyConfiguredCache; + }, + async getVisionSettings() { + const row = await getVisionRow(); + if (!row) { + return { keyId: null, keyName: null, providerLabel: null, visionModel: null, availableModels: [] }; + } + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + return { + keyId: publicRow.id, + keyName: publicRow.name, + providerLabel: publicRow.providerLabel, + visionModel: publicRow.defaultModel, + availableModels: publicRow.models + }; + }, + async setVisionKey(keyId, model) { + const nextModel = String(model ?? "").trim(); + if (!nextModel) return { ok: false, message: "\u8BF7\u9009\u62E9\u89C6\u89C9\u6A21\u578B" }; + const row = await getRowById(keyId); + if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" }; + if (row.status !== "active") return { ok: false, message: "\u8BE5\u914D\u7F6E\u5DF2\u7981\u7528" }; + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (!publicRow.models.includes(nextModel)) { + return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u8BE5\u914D\u7F6E\u652F\u6301\u5217\u8868\u4E2D" }; + } + await clearVisionSelected(); + const now = Date.now(); + await pool.query( + "UPDATE h5_llm_provider_keys SET is_vision_selected = 1, default_model = ?, updated_at = ? WHERE id = ?", + [nextModel, now, keyId] + ); + visionKeyConfiguredCache = true; + return { ok: true, vision: await this.getVisionSettings() }; + }, + async clearVisionKey() { + await clearVisionSelected(); + visionKeyConfiguredCache = false; + return { ok: true }; + }, + async applyVisionProviderForSession(sessionId, fetchImpl = apiFetchImpl) { + const row = await getVisionRow(); + if (!row) return { ok: false, message: "\u672A\u914D\u7F6E\u56FE\u7247\u4EFB\u52A1\u6A21\u578B" }; + const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl); + let goosedProviderId; + try { + goosedProviderId = await syncRow(row, fetchImpl); + if (!goosedProviderId) { + goosedProviderId = profileFromRow(row, decryptRow).goosedProviderId ?? row.provider_id; + } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : "\u540C\u6B65\u89C6\u89C9\u6A21\u578B\u5931\u8D25" }; + } + await updateSessionProvider(sessionGoosedApi, sessionId, goosedProviderId, row.default_model); + return { ok: true, providerId: goosedProviderId, model: row.default_model, source: "vision" }; + }, + // Calls the vision provider directly (not through Goose) to analyze images. + // Returns the model's text description, or null on failure. + async analyzeImagesWithVision(imageItems, userText) { + const row = await getVisionRow(); + if (!row) return null; + const apiUrl = String(row.api_url ?? "").trim(); + if (!apiUrl) return null; + const apiKey = decryptRow(row); + if (!apiKey) return null; + const model = String(row.default_model ?? "qwen-vl-max").trim(); + const content = [ + ...imageItems.map((item) => ({ + type: "image_url", + image_url: { url: `data:${item.mimeType};base64,${item.data}` } + })), + { type: "text", text: String(userText || "\u8BF7\u63CF\u8FF0\u8FD9\u5F20\u56FE\u7247\u7684\u5185\u5BB9").trim() } + ]; + try { + const resp = await undiciFetch3(`${apiUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content }], + max_tokens: 1200 + }), + signal: AbortSignal.timeout(3e4) + }); + if (!resp.ok) return null; + const json = await resp.json(); + return json?.choices?.[0]?.message?.content ?? null; + } catch { + return null; + } + }, + async getGlobalSettings() { + const row = await getSelectedRow(); + if (!row) { + return { + keyId: null, + keyName: null, + providerLabel: null, + globalModel: null, + availableModels: [] + }; + } + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + return { + keyId: publicRow.id, + keyName: publicRow.name, + providerLabel: publicRow.providerLabel, + globalModel: publicRow.defaultModel, + availableModels: publicRow.models + }; + }, + async setGlobalModel(model) { + const nextModel = String(model ?? "").trim(); + if (!nextModel) return { ok: false, message: "\u8BF7\u9009\u62E9\u5168\u5C40\u6A21\u578B" }; + const row = await getSelectedRow(); + if (!row) return { ok: false, message: "\u8BF7\u5148\u542F\u7528\u4E00\u4E2A LLM \u914D\u7F6E" }; + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (!publicRow.models.includes(nextModel)) { + return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" }; + } + await pool.query( + "UPDATE h5_llm_provider_keys SET default_model = ?, updated_at = ? WHERE id = ?", + [nextModel, Date.now(), row.id] + ); + await syncRow(await getRowById(row.id)); + return { ok: true, global: await this.getGlobalSettings() }; + }, + async testDraft(payload) { + const custom = isCustomPayload(payload); + if (!custom) { + return { ok: false, message: "\u8054\u901A\u6D4B\u8BD5\u76EE\u524D\u652F\u6301\u81EA\u5B9A\u4E49 OpenAI \u517C\u5BB9\u914D\u7F6E" }; + } + const validated = validateCustomPayload(payload); + if (!validated.ok) return validated; + const model = String(payload?.testModel ?? payload?.defaultModel ?? validated.value.defaultModel).trim(); + if (!validated.value.models.includes(model)) { + return { ok: false, message: "\u6D4B\u8BD5\u6A21\u578B\u4E0D\u5728\u6A21\u578B\u5217\u8868\u4E2D" }; + } + return testRelayConnection( + { + apiUrl: validated.value.apiUrl, + apiKey: validated.value.apiKey, + model, + relayProvider: validated.value.relayProvider + }, + apiFetchImpl + ); + }, + async testKey(id, testModel) { + const row = await getRowById(id); + if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" }; + const model = String(testModel ?? row.default_model).trim(); + const providerKind = row.provider_kind ?? "builtin"; + const meta = catalogItem(row.provider_id); + const models = providerKind === "custom" ? parseModelsJson(row.models_json) : meta?.models ?? []; + if (!models.includes(model)) { + return { ok: false, message: "\u6D4B\u8BD5\u6A21\u578B\u4E0D\u5728\u914D\u7F6E\u5217\u8868\u4E2D" }; + } + if (providerKind !== "custom") { + const apiUrl = BUILTIN_PROVIDER_TEST_URLS[row.provider_id]; + if (!apiUrl) { + return { ok: false, message: "\u8BE5\u5185\u7F6E Provider \u6682\u4E0D\u652F\u6301\u8054\u901A\u6D4B\u8BD5" }; + } + return testRelayConnection( + { + apiUrl, + apiKey: decryptRow(row), + model, + relayProvider: null + }, + apiFetchImpl + ); + } + return testRelayConnection( + { + apiUrl: row.api_url, + apiKey: decryptRow(row), + model, + relayProvider: row.relay_provider + }, + apiFetchImpl + ); + }, + async ensureBootstrapRelay() { + const [existing] = await pool.query( + "SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1", + [RELAY_BOOTSTRAP.name] + ); + if (existing.length > 0) { + return { ok: true, created: false, keyId: existing[0].id }; + } + const result = await this.createKey({ + providerId: CUSTOM_PROVIDER_ID, + name: RELAY_BOOTSTRAP.name, + apiKey: RELAY_BOOTSTRAP.apiKey, + apiUrl: RELAY_BOOTSTRAP.apiUrl, + models: RELAY_BOOTSTRAP.models, + defaultModel: RELAY_BOOTSTRAP.defaultModel, + relayProvider: RELAY_BOOTSTRAP.relayProvider + }); + if (!result.ok) return result; + if (result.key && !result.key.isSelected) { + await this.selectKey(result.key.id); + } + return { ok: true, created: true, key: result.key }; + } + }; +} + +// mindspace-cover-ai.mjs +var insecureDispatcher4 = new Agent4({ + connect: { rejectUnauthorized: false } +}); +function resolveEncryptionKey2(explicitKey) { + const raw = explicitKey ?? process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"; + return raw; +} +function parseJsonObject(text) { + const source = String(text ?? "").trim(); + if (!source) return null; + try { + return JSON.parse(source); + } catch { + try { + return JSON.parse(jsonrepair(source)); + } catch { + return null; + } + } +} +function extractJsonObject(text) { + const direct = parseJsonObject(text); + if (direct && typeof direct === "object") return direct; + const source = String(text ?? "").trim(); + const start = source.indexOf("{"); + const end = source.lastIndexOf("}"); + if (start < 0 || end <= start) { + throw Object.assign(new Error("AI \u672A\u8FD4\u56DE\u6709\u6548 JSON"), { code: "cover_ai_invalid_output" }); + } + const raw = source.slice(start, end + 1); + try { + return JSON.parse(jsonrepair(raw)); + } catch (error) { + throw Object.assign(new Error("AI \u5C01\u9762 JSON \u89E3\u6790\u5931\u8D25"), { + code: "cover_ai_invalid_output", + cause: error instanceof Error ? error.message : String(error) + }); + } +} +function buildCoverPrompt({ title, summary, html, instruction, currentCover }) { + const excerpt = String(html ?? "").replace(//gi, " ").replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 1200); + return [ + "\u4F60\u662F MindSpace \u4FE1\u606F\u6D41\u5C01\u9762\u8BBE\u8BA1\u52A9\u624B\u3002\u8BF7\u6839\u636E\u9875\u9762\u5185\u5BB9\u751F\u6210 mindspace-cover \u5143\u6570\u636E\u3002", + "\u53EA\u8FD4\u56DE\u4E00\u4E2A JSON \u5BF9\u8C61\uFF0C\u4E0D\u8981 Markdown\uFF0C\u4E0D\u8981\u89E3\u91CA\u3002", + "\u5B57\u6BB5\u8981\u6C42\uFF1A", + "- tag: 2-4 \u4E2A\u6C49\u5B57\u5206\u7C7B\uFF0C\u5982 \u65C5\u884C/\u7F8E\u98DF/\u6D3B\u52A8/\u62A5\u544A", + "- emoji: 1 \u4E2A\u4E0E\u4E3B\u9898\u9AD8\u5EA6\u76F8\u5173\u7684 emoji", + "- accent: \u4E3B\u8272 hex\uFF0C\u5982 #eeb04e", + "- accent2: \u8F85\u8272 hex", + "- subtitle: 12-28 \u5B57\u526F\u6807\u9898\uFF0C\u7528\u4E8E\u5C01\u9762\u5E95\u90E8", + "- cover: \u53EF\u9009\uFF0C\u82E5\u9875\u9762\u5DF2\u6709 hero \u56FE\u8DEF\u5F84\u53EF\u4FDD\u7559\uFF0C\u5426\u5219\u7559\u7A7A\u5B57\u7B26\u4E32", + "", + `\u9875\u9762\u6807\u9898\uFF1A${title || "\u672A\u547D\u540D\u9875\u9762"}`, + `\u9875\u9762\u6458\u8981\uFF1A${summary || "\u65E0"}`, + `\u5F53\u524D cover \u5143\u6570\u636E\uFF1A${JSON.stringify(currentCover ?? {})}`, + `\u9875\u9762\u6B63\u6587\u6458\u5F55\uFF1A${excerpt || "\u65E0"}`, + instruction ? `\u7528\u6237\u8865\u5145\u8981\u6C42\uFF1A${instruction}` : "" + ].filter(Boolean).join("\n"); +} +async function suggestCoverMetaWithAi(pool, { title, summary, html, instruction, encryptionKey }) { + const [rows] = await pool.query( + `SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = 'active' LIMIT 1` + ); + const row = rows[0]; + if (!row) { + throw Object.assign(new Error("\u8BF7\u5148\u5728\u7BA1\u7406\u540E\u53F0\u914D\u7F6E\u5E76\u542F\u7528 LLM"), { code: "llm_not_configured" }); + } + let apiKey; + try { + apiKey = decryptSecret( + { + ciphertext: row.api_key_ciphertext, + iv: row.api_key_iv, + tag: row.api_key_tag + }, + resolveEncryptionKey2(encryptionKey) + ); + } catch { + throw Object.assign(new Error("LLM \u5BC6\u94A5\u89E3\u5BC6\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u914D\u7F6E"), { code: "llm_not_configured" }); + } + const apiUrl = normalizeApiUrl(row.api_url); + const url = resolveChatCompletionsUrl(apiUrl); + if (!url) { + throw Object.assign(new Error("LLM API \u5730\u5740\u65E0\u6548"), { code: "llm_not_configured" }); + } + const prompt = buildCoverPrompt({ + title, + summary, + html, + instruction, + currentCover: parseMindspaceCoverMeta(html) + }); + let upstream; + try { + upstream = await undiciFetch4(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: row.default_model, + messages: [{ role: "user", content: prompt }], + stream: false, + ...row.relay_provider ? { provider: row.relay_provider } : {} + }), + dispatcher: url.startsWith("https://") ? insecureDispatcher4 : void 0 + }); + } catch (error) { + throw Object.assign( + new Error(`AI \u5C01\u9762\u751F\u6210\u5931\u8D25\uFF1A${error instanceof Error ? error.message : "\u7F51\u7EDC\u9519\u8BEF"}`), + { code: "cover_ai_failed" } + ); + } + const text = await upstream.text().catch(() => ""); + if (!upstream.ok) { + throw Object.assign(new Error(`AI \u5C01\u9762\u751F\u6210\u5931\u8D25\uFF1A${text.slice(0, 240) || upstream.status}`), { + code: "cover_ai_failed" + }); + } + let data; + try { + data = JSON.parse(text); + } catch { + throw Object.assign(new Error("AI \u54CD\u5E94\u4E0D\u662F JSON"), { code: "cover_ai_failed" }); + } + const reply = data?.choices?.[0]?.message?.content ?? data?.message?.content ?? data?.output ?? text; + const parsed = normalizeCoverMetaSuggestion(extractJsonObject(reply)); + if (!parsed.tag && !parsed.emoji && !parsed.accent) { + throw Object.assign(new Error("AI \u672A\u751F\u6210\u6709\u6548\u5C01\u9762\u53C2\u6570"), { code: "cover_ai_invalid_output" }); + } + return parsed; +} + +// mindspace-publications.mjs +import crypto12 from "node:crypto"; +import fs15 from "node:fs/promises"; +import path16 from "node:path"; + +// mindspace-html-localize.mjs +var GOOGLE_FONTS_CSS_IMPORT_RE = /@import\s+url\(\s*['"]?(https:\/\/fonts\.googleapis\.com\/[^'")\s]+)['"]?\s*\)\s*;?/gi; +var GOOGLE_FONTS_LINK_RE = /]*\bhref=['"](https:\/\/fonts\.googleapis\.com\/[^'"]+)['"][^>]*>/gi; +async function fetchStylesheet(url) { + const response = await fetch(url, { + headers: { "User-Agent": "TKMind-MindSpace/1.0" }, + signal: AbortSignal.timeout(15e3) + }); + if (!response.ok) { + throw new Error(`\u65E0\u6CD5\u4E0B\u8F7D\u6837\u5F0F\u8868: ${response.status}`); + } + return response.text(); +} +function uniqueUrls(matches) { + return [...new Set(matches.map((value) => value.replace(/&/g, "&")))]; +} +async function localizeGoogleFontsCss(html) { + const source = String(html); + const importUrls = uniqueUrls([...source.matchAll(GOOGLE_FONTS_CSS_IMPORT_RE)].map((m) => m[1])); + const linkUrls = uniqueUrls([...source.matchAll(GOOGLE_FONTS_LINK_RE)].map((m) => m[1])); + const stylesheetUrls = uniqueUrls([...importUrls, ...linkUrls]); + if (!stylesheetUrls.length) { + return { html: source, localizedCount: 0, inlinedBytes: 0 }; + } + const inlinedBlocks = []; + for (const url of stylesheetUrls) { + try { + inlinedBlocks.push(`/* localized from ${url} */ +${await fetchStylesheet(url)}`); + } catch { + inlinedBlocks.push(`/* failed to localize ${url} */`); + } + } + let next = source; + for (const url of importUrls) { + const pattern = new RegExp( + `@import\\s+url\\(\\s*['"]?${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]?\\s*\\)\\s*;?`, + "gi" + ); + next = next.replace(pattern, ""); + } + next = next.replace(GOOGLE_FONTS_LINK_RE, ""); + const styleBlock = ``; + if (/]*>/i.test(next)) { + next = next.replace(/]*)>/i, `${styleBlock}`); + } else if (/]*>/i.test(next)) { + next = next.replace(/]*)>/i, `${styleBlock}`); + } else { + next = `${styleBlock} +${next}`; + } + const inlinedBytes = Buffer.byteLength(inlinedBlocks.join("\n")); + return { html: next, localizedCount: stylesheetUrls.length, inlinedBytes }; +} + +// mindspace-publications.mjs +var SCANNER_VERSION = "mindspace-content-v1"; +var PRIVATE_ASSET_DOWNLOAD_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; +var ACCESS_MODES = /* @__PURE__ */ new Set([ + "public", + "password", + "private_link", + "time_limited", + "login_required", + "owner_only" +]); +var SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; +function publicationError(message, code, details) { + return Object.assign(new Error(message), { code, details }); +} +function normalizeSlug(value) { + const slug = String(value ?? "").normalize("NFKC").trim().toLowerCase(); + if (!SLUG_PATTERN.test(slug)) { + throw publicationError("\u9875\u9762\u5730\u5740\u53EA\u80FD\u5305\u542B\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u548C\u8FDE\u5B57\u7B26", "invalid_publish_input"); + } + return slug; +} +async function findAvailableSlug(pool, userId, preferredSlug, excludePageId) { + const normalized = normalizeSlug(preferredSlug); + const candidates = [normalized]; + for (let index = 2; index <= 20; index += 1) { + candidates.push(`${normalized}-${index}`); + } + candidates.push(`${normalized}-${excludePageId.replace(/-/g, "").slice(0, 8)}`); + for (const candidate of candidates) { + if (!SLUG_PATTERN.test(candidate)) continue; + const [rows] = await pool.query( + `SELECT id FROM h5_publish_records + WHERE user_id = ? AND url_slug = ? AND status = 'online' AND page_id <> ? + LIMIT 1`, + [userId, candidate, excludePageId] + ); + if (!rows[0]) return candidate; + } + return `${normalized}-${crypto12.randomBytes(3).toString("hex")}`; +} +function normalizeAccessMode(value) { + const mode = String(value ?? "public"); + if (!ACCESS_MODES.has(mode)) { + throw publicationError("\u4E0D\u652F\u6301\u7684\u8BBF\u95EE\u65B9\u5F0F", "invalid_publish_input"); + } + return mode; +} +function normalizePassword(value, required) { + const password = String(value ?? ""); + if (!required && !password) return null; + if (password.length < 8 || password.length > 128) { + throw publicationError("\u8BBF\u95EE\u5BC6\u7801\u957F\u5EA6\u5FC5\u987B\u4E3A 8 \u5230 128 \u4E2A\u5B57\u7B26", "invalid_publish_input"); + } + return password; +} +function normalizeExpiresAt(value, required) { + if (!required && (value == null || value === "")) return null; + const expiresAt = Number(value); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) { + throw publicationError("\u8FC7\u671F\u65F6\u95F4\u5FC5\u987B\u665A\u4E8E\u5F53\u524D\u65F6\u95F4", "invalid_publish_input"); + } + return expiresAt; +} +function hashPassword(password) { + const salt = crypto12.randomBytes(16); + const hash = crypto12.scryptSync(password, salt, 32); + return `scrypt$${salt.toString("hex")}$${hash.toString("hex")}`; +} +function verifyPassword2(password, encoded) { + const [algorithm, saltHex, hashHex] = String(encoded ?? "").split("$"); + if (algorithm !== "scrypt" || !saltHex || !hashHex) return false; + const expected = Buffer.from(hashHex, "hex"); + const actual = crypto12.scryptSync(String(password ?? ""), Buffer.from(saltHex, "hex"), expected.length); + return expected.length === actual.length && crypto12.timingSafeEqual(expected, actual); +} +function deviceType(userAgent) { + const value = String(userAgent ?? ""); + if (/bot|crawler|spider|slurp/i.test(value)) return "bot"; + if (/ipad|tablet|kindle|silk/i.test(value)) return "tablet"; + if (/mobile|iphone|android/i.test(value)) return "mobile"; + if (value) return "desktop"; + return "unknown"; +} +function referrerHost(referrer) { + if (!referrer) return null; + try { + return new URL(referrer).hostname.slice(0, 255) || null; + } catch { + return null; + } +} +function publicationResponse(row) { + if (!row) return null; + return { + id: row.id, + pageId: row.page_id, + pageVersionId: row.page_version_id, + urlSlug: row.url_slug, + publicUrl: row.public_url, + accessMode: row.access_mode, + expiresAt: row.expires_at == null ? null : Number(row.expires_at), + status: row.status, + viewCount: Number(row.view_count ?? 0), + publishedAt: Number(row.published_at), + offlineAt: row.offline_at == null ? null : Number(row.offline_at) + }; +} +function publicHomepageResponse(owner, pages) { + const totalViews = pages.reduce( + (sum, page) => sum + Number(page.viewCount ?? page.view_count ?? 0), + 0 + ); + return { + owner: { + id: owner.id, + slug: owner.slug, + username: owner.username, + displayName: owner.display_name || owner.username + }, + totalViews, + pageCount: pages.length, + pages: pages.map((page) => ({ + id: page.id, + pageId: page.page_id, + title: page.title, + summary: page.summary ?? "", + templateId: page.template_id, + publicUrl: page.public_url, + urlSlug: page.url_slug, + viewCount: Number(page.view_count ?? 0), + publishedAt: Number(page.published_at) + })) + }; +} +function buildPublicationThumbnailFallback(ownerSlug, urlSlug) { + return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`; +} +async function localizePrivateImageReferences({ pool, userId, html, absoluteStoragePath }) { + const source = String(html ?? ""); + const matches = [...source.matchAll(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN)]; + if (matches.length === 0) return source; + const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; + if (assetIds.length === 0) return source; + const [assets] = await pool.query( + `SELECT a.id, a.mime_type, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted'`, + [userId, assetIds] + ); + const byId = new Map(assets.map((asset) => [asset.id, asset])); + const replacements = /* @__PURE__ */ new Map(); + for (const assetId of assetIds) { + const asset = byId.get(assetId); + if (!asset) continue; + const mimeType = String(asset.mime_type || "application/octet-stream"); + const buffer = await fs15.readFile(absoluteStoragePath(asset.storage_key)); + replacements.set(assetId, `data:${mimeType};base64,${buffer.toString("base64")}`); + } + if (replacements.size === 0) return source; + return source.replace(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN, (value, assetId) => { + return replacements.get(assetId) ?? value; + }); +} +async function prepareHtmlPublishContent({ + pool, + userId, + html, + ownerSlug, + urlSlug, + absoluteStoragePath +}) { + let publishContent = (await localizeGoogleFontsCss(html)).html; + publishContent = await localizePrivateImageReferences({ + pool, + userId, + html: publishContent, + absoluteStoragePath + }); + return replacePrivateResourceReferences( + publishContent, + buildPublicationThumbnailFallback(ownerSlug, urlSlug) + ); +} +function createPublicationService(pool, options = {}) { + const storageRoot = path16.resolve(options.storageRoot ?? path16.join(process.cwd(), "data", "mindspace")); + const idFactory = options.idFactory ?? (() => crypto12.randomUUID()); + const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); + const resolvePublicPageLimit = async () => { + try { + const config = await loadMindSpaceConfig(pool); + return Number(config.publicPageLimit ?? publicPageLimitFallback); + } catch { + return publicPageLimitFallback; + } + }; + const absoluteStoragePath = (storageKey) => { + const resolved = path16.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path16.sep}`)) { + throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); + } + return resolved; + }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? "").replace(/\\/g, "/"); + const withoutMd = normalized.endsWith(".md") ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}` + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs15.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === "ENOENT") { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); + }; + const loadVersion = async (userId, pageId, pageVersionId) => { + const [rows] = await pool.query( + `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id, + p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no, + pv.bundle_asset_id, av.storage_key + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.page_id = p.id + JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1 + WHERE p.id = ? AND p.user_id = ? AND pv.id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId, pageVersionId] + ); + const row = rows[0]; + if (!row) throw publicationError("\u9875\u9762\u7248\u672C\u4E0D\u5B58\u5728", "page_not_found"); + return { + ...row, + version_no: Number(row.version_no), + content: await fs15.readFile(await resolveReadableStoragePath(row.storage_key), "utf8") + }; + }; + const loadOwnerSlug = async (userId) => { + const [rows] = await pool.query( + `SELECT COALESCE(slug, username) AS public_slug + FROM h5_users + WHERE id = ? + LIMIT 1`, + [userId] + ); + const ownerSlug = String(rows[0]?.public_slug ?? "").trim(); + if (!ownerSlug) throw publicationError("\u7528\u6237\u516C\u5F00\u5730\u5740\u4E0D\u5B58\u5728", "publication_owner_not_found"); + return ownerSlug; + }; + const preparePublishContent = async (page, ownerSlug, urlSlug) => { + let publishContent = page.content; + if (page.page_type !== "html") return publishContent; + return prepareHtmlPublishContent({ + pool, + userId: page.user_id, + html: publishContent, + ownerSlug, + urlSlug, + absoluteStoragePath + }); + }; + const persistScan = async (conn, userId, pageVersionId, scan, now) => { + const scanId = idFactory(); + await conn.query( + `INSERT INTO h5_security_scans + (id, user_id, target_type, target_id, scanner_version, status, risk_level, + findings_count, summary_json, started_at, completed_at) + VALUES (?, ?, 'page_version', ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + scanId, + userId, + pageVersionId, + SCANNER_VERSION, + scan.status, + scan.riskLevel, + scan.findings.length, + JSON.stringify({ types: scan.findings.map((finding) => finding.type) }), + now, + now + ] + ); + for (const finding of scan.findings) { + await conn.query( + `INSERT INTO h5_security_findings + (id, scan_id, finding_type, risk_level, occurrence_count, sample_masked, blocking, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + idFactory(), + scanId, + finding.type, + finding.riskLevel, + finding.occurrenceCount, + finding.sampleMasked, + finding.blocking ? 1 : 0, + now + ] + ); + } + return scanId; + }; + const check = async (userId, pageId, input) => { + const slug = normalizeSlug(input.urlSlug); + const accessMode = normalizeAccessMode(input.accessMode); + normalizePassword(input.password, accessMode === "password"); + const expiresAt = normalizeExpiresAt( + input.expiresAt, + accessMode === "time_limited" + ); + const page = await loadVersion(userId, pageId, input.pageVersionId); + if (page.page_version_id !== page.current_version_id) { + throw publicationError("\u53EA\u80FD\u53D1\u5E03\u9875\u9762\u7684\u5F53\u524D\u7248\u672C", "invalid_state_transition"); + } + const [conflicts] = await pool.query( + `SELECT pr.id, pr.page_id, p.title + FROM h5_publish_records pr + JOIN h5_page_records p ON p.id = pr.page_id + WHERE pr.user_id = ? AND pr.url_slug = ? AND pr.status = 'online' + AND pr.page_id <> ? LIMIT 1`, + [userId, slug, pageId] + ); + const conflict = conflicts[0] ?? null; + const ownerSlug = await loadOwnerSlug(userId); + const publishContent = await preparePublishContent(page, ownerSlug, slug); + const scan = scanContent(`${page.title} +${page.summary ?? ""} +${publishContent}`, { + format: page.page_type === "html" ? "html" : "text", + allowHtmlActiveContent: page.page_type === "html" + }); + const suggestedUrlSlug = conflict && accessMode !== "private_link" ? await findAvailableSlug(pool, userId, slug, pageId) : null; + return { + pageVersionId: page.page_version_id, + urlSlug: slug, + accessMode, + expiresAt, + slugAvailable: accessMode === "private_link" || !conflict, + slugConflict: conflict ? { pageId: conflict.page_id, title: conflict.title } : null, + suggestedUrlSlug, + ...scan, + allowed: (accessMode === "private_link" || !conflict) && scan.allowed + }; + }; + const publish = async (userId, pageId, input) => { + const result = await check(userId, pageId, input); + if (!result.slugAvailable) throw publicationError("\u9875\u9762\u5730\u5740\u5DF2\u88AB\u4F7F\u7528", "slug_conflict"); + if (!result.allowed) { + throw publicationError("\u9875\u9762\u5305\u542B\u4E0D\u53EF\u516C\u5F00\u7684\u9AD8\u98CE\u9669\u4FE1\u606F", "security_risk_blocked", { + findings: result.findings + }); + } + const acknowledged = new Set(input.acknowledgedFindingIds ?? []); + const missingAcknowledgements = result.findings.filter( + (finding) => !finding.blocking && !acknowledged.has(finding.id) + ); + if (missingAcknowledgements.length) { + throw publicationError("\u8BF7\u5148\u786E\u8BA4\u53D1\u5E03\u68C0\u67E5\u4E2D\u7684\u98CE\u9669\u63D0\u793A", "security_ack_required", { + findings: missingAcknowledgements + }); + } + const page = await loadVersion(userId, pageId, result.pageVersionId); + const ownerSlug = await loadOwnerSlug(userId); + const publishContent = await preparePublishContent(page, ownerSlug, result.urlSlug); + const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent }); + const htmlBytes = Buffer.byteLength(html); + const conn = await pool.getConnection(); + let writtenPath; + try { + await conn.beginTransaction(); + const [spaces] = await conn.query( + `SELECT quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces + WHERE id = ? AND user_id = ? FOR UPDATE`, + [page.space_id, userId] + ); + const available = Number(spaces[0]?.quota_bytes ?? 0) - Number(spaces[0]?.used_bytes ?? 0) - Number(spaces[0]?.reserved_bytes ?? 0); + if (available < htmlBytes) { + throw publicationError("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3", "quota_exceeded", { + requiredBytes: htmlBytes, + availableBytes: Math.max(0, available) + }); + } + const [publicationUsage] = await conn.query( + `SELECT COUNT(DISTINCT page_id) AS public_page_used, + MAX(CASE WHEN page_id = ? THEN 1 ELSE 0 END) AS page_already_online + FROM h5_publish_records + WHERE user_id = ? AND status = 'online'`, + [pageId, userId] + ); + const publicPageLimit = await resolvePublicPageLimit(); + if (!Number(publicationUsage[0]?.page_already_online) && Number(publicationUsage[0]?.public_page_used) >= publicPageLimit) { + throw publicationError("\u516C\u5F00\u9875\u9762\u6570\u91CF\u5DF2\u8FBE\u5230\u5957\u9910\u4E0A\u9650", "public_page_limit_exceeded", { + limit: publicPageLimit + }); + } + const [categories] = await conn.query( + `SELECT id FROM h5_space_categories + WHERE user_id = ? AND space_id = ? AND category_code = 'public' LIMIT 1`, + [userId, page.space_id] + ); + if (!categories[0]) throw publicationError("\u516C\u5F00\u5206\u7C7B\u4E0D\u5B58\u5728", "category_not_found"); + const now = Date.now(); + const scanId = await persistScan(conn, userId, page.page_version_id, result, now); + const bundleAssetId = idFactory(); + const assetVersionId = idFactory(); + const publishId = idFactory(); + const privateToken = result.accessMode === "private_link" ? crypto12.randomBytes(24).toString("base64url") : null; + const tokenHash = privateToken ? crypto12.createHash("sha256").update(privateToken).digest("hex") : null; + const passwordHash = result.accessMode === "password" ? hashPassword(input.password) : null; + const storageKey = path16.posix.join( + "users", + userId, + "publications", + publishId, + "index.html" + ); + writtenPath = absoluteStoragePath(storageKey); + await fs15.mkdir(path16.dirname(writtenPath), { recursive: true }); + await fs15.writeFile(writtenPath, html, { flag: "wx" }); + const checksum = crypto12.createHash("sha256").update(html).digest("hex"); + const publicBaseUrl = resolvePublicBaseUrl(); + const publicUrl2 = privateToken ? `/s/${privateToken}` : `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`; + await conn.query( + `INSERT INTO h5_assets + (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, + display_name, current_version_id, size_bytes, checksum, risk_level, visibility, + status, source_type, created_at, updated_at) + VALUES (?, ?, ?, ?, 'html', 'text/html', 'index.html', ?, ?, ?, ?, ?, + 'public_candidate', 'ready', 'generated', ?, ?)`, + [ + bundleAssetId, + userId, + page.space_id, + categories[0].id, + `${page.title} \xB7 \u53D1\u5E03\u5FEB\u7167`, + assetVersionId, + htmlBytes, + checksum, + result.riskLevel, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_asset_versions + (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, + created_by, change_note, scan_status, created_at) + VALUES (?, ?, 1, ?, ?, ?, 'text/html', ?, '\u53D1\u5E03\u9875\u9762\u5FEB\u7167', ?, ?)`, + [ + assetVersionId, + bundleAssetId, + storageKey, + htmlBytes, + checksum, + userId, + result.status === "passed" ? "passed" : "warned", + now + ] + ); + const [previousRows] = await conn.query( + `SELECT id, page_version_id FROM h5_publish_records + WHERE user_id = ? AND page_id = ? + ORDER BY published_at DESC LIMIT 1 FOR UPDATE`, + [userId, pageId] + ); + await conn.query( + `UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ? + WHERE user_id = ? AND page_id = ? AND status = 'online'`, + [now, now, userId, pageId] + ); + await conn.query( + `UPDATE h5_page_versions + SET immutable = 1, bundle_asset_id = ?, security_scan_id = ? + WHERE id = ? AND page_id = ?`, + [bundleAssetId, scanId, page.page_version_id, pageId] + ); + await conn.query( + `INSERT INTO h5_publish_records + (id, user_id, page_id, page_version_id, publish_type, url_slug, public_url, + access_mode, password_hash, token_hash, token_prefix, expires_at, published_at, + status, view_count, security_scan_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`, + [ + publishId, + userId, + pageId, + page.page_version_id, + result.urlSlug, + publicUrl2, + result.accessMode, + passwordHash, + tokenHash, + privateToken?.slice(0, 8) ?? null, + result.expiresAt, + now, + scanId, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_publication_events + (id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id, + access_mode, detail_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + idFactory(), + publishId, + previousRows.length ? "republished" : "published", + userId, + previousRows[0]?.page_version_id ?? null, + page.page_version_id, + result.accessMode, + JSON.stringify({ findings: result.findings.map((finding) => finding.id) }), + now + ] + ); + await conn.query( + `UPDATE h5_page_records + SET current_publish_id = ?, status = ?, visibility = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [ + publishId, + result.accessMode === "public" ? "published" : "protected", + result.accessMode === "public" ? "public" : "private", + now, + pageId, + userId + ] + ); + await conn.query( + `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [htmlBytes, now, page.space_id, userId] + ); + await conn.commit(); + return { + ...publicationResponse({ + id: publishId, + page_id: pageId, + page_version_id: page.page_version_id, + url_slug: result.urlSlug, + public_url: publicUrl2, + access_mode: result.accessMode, + status: "online", + view_count: 0, + published_at: now, + offline_at: null, + expires_at: result.expiresAt + }), + findings: result.findings + }; + } catch (error) { + await conn.rollback(); + if (writtenPath) await fs15.rm(writtenPath, { force: true }).catch(() => { + }); + throw error; + } finally { + conn.release(); + } + }; + const getCurrent = async (userId, pageId) => { + const [rows] = await pool.query( + `SELECT pr.* FROM h5_publish_records pr + JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id + WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online' + ORDER BY pr.published_at DESC LIMIT 1`, + [pageId, userId] + ); + return publicationResponse(rows[0]); + }; + const offline = async (userId, publicationId) => { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT * FROM h5_publish_records + WHERE id = ? AND user_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`, + [publicationId, userId] + ); + const publication = rows[0]; + if (!publication) throw publicationError("\u53D1\u5E03\u8BB0\u5F55\u4E0D\u5B58\u5728", "publication_not_found"); + const now = Date.now(); + await conn.query( + `UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ? + WHERE id = ?`, + [now, now, publicationId] + ); + await conn.query( + `UPDATE h5_page_records + SET current_publish_id = NULL, status = 'offline', visibility = 'private', updated_at = ? + WHERE id = ? AND user_id = ? AND current_publish_id = ?`, + [now, publication.page_id, userId, publicationId] + ); + await conn.query( + `INSERT INTO h5_publication_events + (id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id, + access_mode, detail_json, created_at) + VALUES (?, ?, 'offlined', ?, ?, NULL, ?, '{}', ?)`, + [ + idFactory(), + publicationId, + userId, + publication.page_version_id, + publication.access_mode, + now + ] + ); + await conn.query( + `UPDATE plaza_posts SET status = 'hidden', updated_at = ? + WHERE publication_id = ? AND status != 'hidden'`, + [now, publicationId] + ); + await conn.commit(); + return { id: publicationId, status: "offline", offlineAt: now }; + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const resolveRow = async (row, viewerId, password, requestMeta = {}) => { + if (!row) throw publicationError("\u516C\u5F00\u9875\u9762\u4E0D\u5B58\u5728", "publication_not_found"); + if (row.expires_at && Number(row.expires_at) <= Date.now()) { + await pool.query( + `UPDATE h5_publish_records SET status = 'expired', updated_at = ? WHERE id = ?`, + [Date.now(), row.id] + ); + throw publicationError("\u516C\u5F00\u9875\u9762\u5DF2\u8FC7\u671F", "publication_not_found"); + } + if (row.access_mode === "owner_only" && row.owner_id !== viewerId) { + throw publicationError("\u516C\u5F00\u9875\u9762\u4E0D\u5B58\u5728", "publication_not_found"); + } + if (row.access_mode === "login_required" && !viewerId) { + throw publicationError("\u767B\u5F55\u540E\u624D\u80FD\u8BBF\u95EE\u6B64\u9875\u9762", "publication_login_required"); + } + if (row.access_mode === "password" && !verifyPassword2(password, row.password_hash)) { + throw publicationError("\u9700\u8981\u8BBF\u95EE\u5BC6\u7801", "publication_password_required"); + } + const now = Date.now(); + await pool.query( + `UPDATE h5_publish_records SET view_count = view_count + 1, updated_at = ? WHERE id = ?`, + [now, row.id] + ); + await pool.query( + `INSERT INTO h5_publication_views + (id, publish_id, viewer_user_id, referrer_host, device_type, viewed_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + idFactory(), + row.id, + viewerId ?? null, + referrerHost(requestMeta.referrer), + deviceType(requestMeta.userAgent), + now + ] + ); + return { + html: await fs15.readFile(await resolveReadableStoragePath(row.storage_key), "utf8"), + publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }) + }; + }; + const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => { + const [rows] = await pool.query( + `SELECT pr.*, u.id AS owner_id, av.storage_key + FROM h5_publish_records pr + JOIN h5_users u ON u.id = pr.user_id + JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 + JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 + WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online' + AND pr.access_mode <> 'private_link' + ORDER BY pr.published_at DESC LIMIT 1`, + [ownerSlug, urlSlug] + ); + return resolveRow(rows[0], viewerId, password, requestMeta); + }; + const resolvePrivateLink = async (token, viewerId, requestMeta) => { + const tokenHash = crypto12.createHash("sha256").update(String(token)).digest("hex"); + const [rows] = await pool.query( + `SELECT pr.*, u.id AS owner_id, av.storage_key + FROM h5_publish_records pr + JOIN h5_users u ON u.id = pr.user_id + JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 + JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 + WHERE pr.token_hash = ? AND pr.status = 'online' AND pr.access_mode = 'private_link' + LIMIT 1`, + [tokenHash] + ); + return resolveRow(rows[0], viewerId, null, requestMeta); + }; + const getStats = async (userId, publicationId) => { + const [publications] = await pool.query( + `SELECT id, view_count, published_at, status + FROM h5_publish_records WHERE id = ? AND user_id = ? LIMIT 1`, + [publicationId, userId] + ); + const publication = publications[0]; + if (!publication) throw publicationError("\u53D1\u5E03\u8BB0\u5F55\u4E0D\u5B58\u5728", "publication_not_found"); + const since = Date.now() - 30 * 24 * 60 * 60 * 1e3; + const [daily] = await pool.query( + `SELECT DATE_FORMAT(FROM_UNIXTIME(viewed_at / 1000), '%Y-%m-%d') AS day, + COUNT(*) AS views + FROM h5_publication_views + WHERE publish_id = ? AND viewed_at >= ? + GROUP BY day ORDER BY day`, + [publicationId, since] + ); + const [devices] = await pool.query( + `SELECT device_type, COUNT(*) AS views + FROM h5_publication_views WHERE publish_id = ? + GROUP BY device_type ORDER BY views DESC`, + [publicationId] + ); + const [sources] = await pool.query( + `SELECT COALESCE(referrer_host, 'direct') AS source, COUNT(*) AS views + FROM h5_publication_views WHERE publish_id = ? + GROUP BY source ORDER BY views DESC LIMIT 10`, + [publicationId] + ); + return { + publicationId, + status: publication.status, + totalViews: Number(publication.view_count), + publishedAt: Number(publication.published_at), + daily: daily.map((row) => ({ day: row.day, views: Number(row.views) })), + devices: devices.map((row) => ({ + deviceType: row.device_type, + views: Number(row.views) + })), + sources: sources.map((row) => ({ source: row.source, views: Number(row.views) })) + }; + }; + const getPublicHomepage = async (ownerSlug) => { + const [owners] = await pool.query( + `SELECT id, username, COALESCE(slug, username) AS slug, display_name + FROM h5_users + WHERE COALESCE(slug, username) = ? + LIMIT 1`, + [ownerSlug] + ); + const owner = owners[0]; + if (!owner) throw publicationError("\u516C\u5F00\u4E3B\u9875\u4E0D\u5B58\u5728", "publication_not_found"); + const [rows] = await pool.query( + `SELECT pr.id, pr.page_id, pr.url_slug, pr.public_url, pr.view_count, pr.published_at, + p.title, p.summary, p.template_id + FROM h5_publish_records pr + JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id + WHERE pr.user_id = ? AND pr.status = 'online' AND pr.access_mode = 'public' + ORDER BY pr.published_at DESC + LIMIT 48`, + [owner.id] + ); + return publicHomepageResponse(owner, rows); + }; + return { + check, + publish, + getCurrent, + getPublicHomepage, + getStats, + offline, + resolvePublic, + resolvePrivateLink + }; +} + +// plaza-posts.mjs +import crypto13 from "node:crypto"; + +// plaza-algorithm.mjs +var DEFAULT_CONFIG = { + w_view: 0.1, + w_like: 3, + w_comment: 5, + w_collect: 4, + decay_base: 2, + decay_exp: 1.5 +}; +function computeHotScore(stats, config = DEFAULT_CONFIG, nowMs2 = Date.now()) { + const publishedAt = Number(stats.published_at ?? stats.publishedAt ?? nowMs2); + const ageHours = Math.max(0, (nowMs2 - publishedAt) / 36e5); + const numerator = Number(stats.view_count ?? 0) * config.w_view + Number(stats.like_count ?? 0) * config.w_like + Number(stats.comment_count ?? 0) * config.w_comment + Number(stats.collect_count ?? 0) * config.w_collect; + const denominator = Math.pow(ageHours + config.decay_base, config.decay_exp); + if (denominator <= 0) return 0; + return Math.max(0, numerator / denominator); +} +async function loadAlgorithmConfig(pool) { + const [rows] = await pool.query(`SELECT \`key\`, value FROM plaza_algorithm_config`); + const config = { ...DEFAULT_CONFIG }; + for (const row of rows) { + if (row.key in config) config[row.key] = Number(row.value); + } + return config; +} +async function ensureAlgorithmConfig(pool) { + const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM plaza_algorithm_config`); + if (Number(rows[0]?.count ?? 0) > 0) return; + const now = Date.now(); + const seeds = [ + ["w_view", 0.1, "\u6D4F\u89C8\u91CF\u6743\u91CD"], + ["w_like", 3, "\u70B9\u8D5E\u6743\u91CD"], + ["w_comment", 5, "\u8BC4\u8BBA\u6743\u91CD"], + ["w_collect", 4, "\u6536\u85CF\u6743\u91CD"], + ["decay_base", 2, "\u65F6\u95F4\u8870\u51CF\u57FA\u6570"], + ["decay_exp", 1.5, "\u65F6\u95F4\u8870\u51CF\u6307\u6570"] + ]; + for (const [key, value, description] of seeds) { + await pool.query( + `INSERT INTO plaza_algorithm_config (\`key\`, value, description, updated_at) + VALUES (?, ?, ?, ?)`, + [key, value, description, now] + ); + } +} +async function recalculateHotScores(pool, { windowHours = 48 } = {}) { + await ensureAlgorithmConfig(pool); + const config = await loadAlgorithmConfig(pool); + const now = Date.now(); + const windowStart = now - windowHours * 36e5; + const [rows] = await pool.query( + `SELECT id, view_count, like_count, comment_count, collect_count, published_at + FROM plaza_posts + WHERE status = 'published' AND published_at >= ?`, + [windowStart] + ); + if (rows.length === 0) return { updated: 0 }; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + for (const row of rows) { + const hotScore = computeHotScore(row, config, now); + await conn.query( + `UPDATE plaza_posts SET hot_score = ?, hot_updated_at = ?, updated_at = ? WHERE id = ?`, + [hotScore, now, now, row.id] + ); + } + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + return { updated: rows.length }; +} + +// plaza-posts.mjs +var DEFAULT_CATEGORIES = [ + { name: "\u804C\u573A\u62A5\u544A", slug: "work-report", icon: "\u{1F4CA}", sort_order: 1 }, + { name: "\u5B66\u4E60\u7B14\u8BB0", slug: "study-notes", icon: "\u{1F4DA}", sort_order: 2 }, + { name: "\u521B\u610F\u4F5C\u54C1", slug: "creative", icon: "\u{1F3A8}", sort_order: 3 }, + { name: "\u95E8\u5E97\u5C55\u793A", slug: "business", icon: "\u{1F3EA}", sort_order: 4 }, + { name: "\u65C5\u884C\u653B\u7565", slug: "travel", icon: "\u2708\uFE0F", sort_order: 5 }, + { name: "\u6570\u636E\u5206\u6790", slug: "data-analysis", icon: "\u{1F4C8}", sort_order: 6 }, + { name: "\u751F\u6D3B\u8BB0\u5F55", slug: "lifestyle", icon: "\u{1F33F}", sort_order: 7 }, + { name: "\u5176\u4ED6", slug: "other", icon: "\u{1F4A1}", sort_order: 99 } +]; +function plazaError(message, code, details) { + return Object.assign(new Error(message), { code, details }); +} +function normalizeTags(value) { + if (!Array.isArray(value)) return []; + const tags = value.map((item) => String(item ?? "").trim()).filter(Boolean).slice(0, 5); + return tags; +} +function clampLimit(value, fallback = 20, max = 50) { + const limit = Number(value ?? fallback); + if (!Number.isFinite(limit) || limit < 1) return fallback; + return Math.min(Math.floor(limit), max); +} +function normalizeSort(value) { + const raw = String(value ?? "recommend").trim().toLowerCase(); + if (raw === "new") return "new"; + if (raw === "hot") return "hot"; + return "recommend"; +} +function defaultPlazaCoverUrl(publicUrl2) { + const value = String(publicUrl2 ?? "").trim(); + if (!value) return ""; + const [pathname, suffix = ""] = value.split(/([?#].*)/, 2); + if (!pathname) return ""; + const nextPath = pathname.endsWith("/") ? `${pathname}index.thumbnail.png` : `${pathname}.thumbnail.png`; + return `${nextPath}${suffix}`; +} +function resolvePlazaCoverUrl(inputCoverUrl, publicUrl2) { + const value = String(inputCoverUrl ?? "").trim(); + if (!value) return defaultPlazaCoverUrl(publicUrl2); + if (/^https?:\/\//i.test(value)) return value; + const base = String(publicUrl2 ?? "").trim(); + if (base && /^https?:\/\//i.test(base)) { + try { + return new URL(value, base).toString(); + } catch { + return value; + } + } + return value; +} +function formatStats(row) { + return { + view_count: Number(row.view_count ?? 0), + like_count: Number(row.like_count ?? 0), + collect_count: Number(row.collect_count ?? 0), + comment_count: Number(row.comment_count ?? 0), + share_count: Number(row.share_count ?? 0) + }; +} +var FRESH_POST_WINDOW_MS = 24 * 60 * 60 * 1e3; +function promoteFreshPosts(items, now = Date.now()) { + const fresh = []; + const stable = []; + for (const post of items) { + const publishedAt = Date.parse(post.published_at); + if (Number.isFinite(publishedAt) && now - publishedAt <= FRESH_POST_WINDOW_MS) { + fresh.push(post); + } else { + stable.push(post); + } + } + fresh.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at)); + return [...fresh, ...stable]; +} +function formatPostRow(row, { category, viewerReacted = null, publicationUrl = null } = {}) { + const resolvedPublicationUrl = publicationUrl ?? row.public_url ?? null; + return { + id: row.id, + title: row.title, + summary: row.summary ?? "", + cover_url: resolvePlazaCoverUrl(row.cover_url, resolvedPublicationUrl), + category: category ?? { + id: row.category_id, + name: row.category_name ?? "", + slug: row.category_slug ?? "", + icon: row.category_icon ?? "" + }, + tags: Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? "[]"), + author: { + user_id: row.user_id, + slug: row.user_slug, + display_name: row.user_display_name, + avatar_url: row.user_avatar_url ?? "" + }, + stats: formatStats(row), + viewer_reacted: viewerReacted, + published_at: new Date(Number(row.published_at)).toISOString(), + ...resolvedPublicationUrl ? { publication_url: resolvedPublicationUrl } : {}, + ...row.allow_comment != null ? { allow_comment: Boolean(row.allow_comment) } : {}, + ...row.status ? { status: row.status } : {} + }; +} +function createPlazaPostService(pool, { + idFactory = () => crypto13.randomUUID(), + loadViewerReactions = null, + plazaRedis: plazaRedis2 = null, + algorithmConfig = null, + onPostPublished = null, + loadFeaturedPosts = null, + recommendService = null +} = {}) { + const autoApprove = String(process.env.PLAZA_AUTO_APPROVE ?? "").toLowerCase() === "true"; + const resolveHotScore = async (publishedAt) => { + const config = algorithmConfig ?? { + w_view: 0.1, + w_like: 3, + w_comment: 5, + w_collect: 4, + decay_base: 2, + decay_exp: 1.5 + }; + return computeHotScore( + { + view_count: 0, + like_count: 0, + comment_count: 0, + collect_count: 0, + published_at: publishedAt + }, + config + ); + }; + const ensureCategories = async () => { + const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM plaza_categories`); + if (Number(rows[0]?.count ?? 0) > 0) return; + const now = Date.now(); + for (const category of DEFAULT_CATEGORIES) { + await pool.query( + `INSERT INTO plaza_categories + (id, name, slug, icon, description, sort_order, is_active, created_at) + VALUES (?, ?, ?, ?, '', ?, 1, ?)`, + [idFactory(), category.name, category.slug, category.icon, category.sort_order, now] + ); + } + }; + const listCategories = async () => { + await ensureCategories(); + const [rows] = await pool.query( + `SELECT c.id, c.name, c.slug, c.icon, + COUNT(p.id) AS post_count + FROM plaza_categories c + LEFT JOIN plaza_posts p + ON p.category_id = c.id AND p.status = 'published' + WHERE c.is_active = 1 + GROUP BY c.id, c.name, c.slug, c.icon, c.sort_order + ORDER BY c.sort_order ASC, c.name ASC` + ); + return rows.map((row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + icon: row.icon ?? "", + post_count: Number(row.post_count ?? 0) + })); + }; + const resolveCategoryId = async (categoryId, categorySlug) => { + await ensureCategories(); + if (categoryId) { + const [rows] = await pool.query( + `SELECT id FROM plaza_categories WHERE id = ? AND is_active = 1 LIMIT 1`, + [categoryId] + ); + if (!rows[0]) throw plazaError("\u5206\u7C7B\u4E0D\u5B58\u5728", "category_not_found"); + return rows[0].id; + } + if (categorySlug) { + const [rows] = await pool.query( + `SELECT id FROM plaza_categories WHERE slug = ? AND is_active = 1 LIMIT 1`, + [categorySlug] + ); + if (!rows[0]) throw plazaError("\u5206\u7C7B\u4E0D\u5B58\u5728", "category_not_found"); + return rows[0].id; + } + throw plazaError("\u5FC5\u987B\u9009\u62E9\u5206\u7C7B", "category_required"); + }; + const loadPublicationContext = async (userId, publicationId) => { + const [rows] = await pool.query( + `SELECT pr.id, pr.user_id, pr.status, pr.public_url, + p.title, p.summary, p.cover_image_asset_id + FROM h5_publish_records pr + JOIN h5_page_records p ON p.id = pr.page_id + WHERE pr.id = ? AND pr.user_id = ? + LIMIT 1`, + [publicationId, userId] + ); + const row = rows[0]; + if (!row) throw plazaError("\u53D1\u5E03\u8BB0\u5F55\u4E0D\u5B58\u5728", "publication_not_found"); + if (row.status !== "online") { + throw plazaError("\u53D1\u5E03\u6E90\u4E0D\u662F\u5728\u7EBF\u72B6\u6001", "PUBLICATION_NOT_ONLINE"); + } + return row; + }; + const loadUserSnapshot = async (userId) => { + const [rows] = await pool.query( + `SELECT id, slug, display_name, username, plaza_post_banned + FROM h5_users WHERE id = ? LIMIT 1`, + [userId] + ); + const row = rows[0]; + if (!row) throw plazaError("\u7528\u6237\u4E0D\u5B58\u5728", "user_not_found"); + if (row.plaza_post_banned) throw plazaError("\u65E0\u53D1\u5E16\u6743\u9650", "POST_PERMISSION_DENIED"); + return { + user_slug: row.slug || row.username, + user_display_name: row.display_name || row.username, + user_avatar_url: "" + }; + }; + const createPost = async (userId, input) => { + const publicationId = String(input?.publication_id ?? "").trim(); + if (!publicationId) throw plazaError("publication_id \u4E0D\u80FD\u4E3A\u7A7A", "invalid_input"); + const [existing] = await pool.query( + `SELECT id FROM plaza_posts WHERE publication_id = ? LIMIT 1`, + [publicationId] + ); + if (existing[0]) throw plazaError("\u8BE5\u5185\u5BB9\u5DF2\u53D1\u5E03\u5230\u5E7F\u573A", "ALREADY_PUBLISHED"); + const publication = await loadPublicationContext(userId, publicationId); + const categoryId = await resolveCategoryId(input?.category_id, input?.category_slug); + const userSnapshot = await loadUserSnapshot(userId); + const tags = normalizeTags(input?.tags); + const coverUrl = resolvePlazaCoverUrl(input?.cover_url, publication.public_url); + const allowComment = input?.allow_comment == null ? true : Boolean(input.allow_comment); + const now = Date.now(); + const postId = idFactory(); + const status = autoApprove ? "published" : "pending_review"; + const hotScore = status === "published" ? await resolveHotScore(now) : 0; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO plaza_posts + (id, publication_id, user_id, title, summary, cover_url, + user_slug, user_display_name, user_avatar_url, + category_id, tags, status, allow_comment, hot_score, hot_updated_at, + published_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + postId, + publicationId, + userId, + publication.title, + publication.summary ?? "", + coverUrl, + userSnapshot.user_slug, + userSnapshot.user_display_name, + userSnapshot.user_avatar_url, + categoryId, + JSON.stringify(tags), + status, + allowComment ? 1 : 0, + hotScore, + status === "published" ? now : null, + now, + now, + now + ] + ); + await conn.query( + `UPDATE h5_users SET plaza_post_count = plaza_post_count + 1, updated_at = ? WHERE id = ?`, + [now, userId] + ); + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + if (status === "published") { + await plazaRedis2?.invalidateFeedCaches?.(); + await onPostPublished?.(postId); + } + return { id: postId, status }; + }; + const updatePost = async (userId, postId, input) => { + const [rows] = await pool.query( + `SELECT pp.*, pr.public_url + FROM plaza_posts pp + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.id = ? AND pp.user_id = ? LIMIT 1`, + [postId, userId] + ); + const post = rows[0]; + if (!post) throw plazaError("\u5E16\u5B50\u4E0D\u5B58\u5728", "POST_NOT_FOUND"); + const updates = []; + const params = []; + if (input?.category_id != null || input?.category_slug != null) { + const categoryId = await resolveCategoryId(input?.category_id, input?.category_slug); + updates.push("category_id = ?"); + params.push(categoryId); + } + if (input?.tags != null) { + updates.push("tags = ?"); + params.push(JSON.stringify(normalizeTags(input.tags))); + } + if (input?.cover_url != null) { + updates.push("cover_url = ?"); + params.push(resolvePlazaCoverUrl(input.cover_url, post.public_url)); + } + if (input?.allow_comment != null) { + updates.push("allow_comment = ?"); + params.push(input.allow_comment ? 1 : 0); + } + if (updates.length === 0) throw plazaError("\u6CA1\u6709\u53EF\u66F4\u65B0\u7684\u5B57\u6BB5", "invalid_input"); + let status = post.status; + if (status === "rejected") status = "pending_review"; + updates.push("status = ?"); + updates.push("updated_at = ?"); + params.push(status, Date.now(), postId, userId); + await pool.query( + `UPDATE plaza_posts SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, + params + ); + return { id: postId, status }; + }; + const hidePost = async (userId, postId) => { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE plaza_posts SET status = 'hidden', updated_at = ? + WHERE id = ? AND user_id = ? AND status != 'hidden'`, + [now, postId, userId] + ); + if (result.affectedRows === 0) throw plazaError("\u5E16\u5B50\u4E0D\u5B58\u5728", "POST_NOT_FOUND"); + return { id: postId, status: "hidden" }; + }; + const hidePostsByPublicationId = async (conn, publicationId, now = Date.now()) => { + await conn.query( + `UPDATE plaza_posts SET status = 'hidden', updated_at = ? + WHERE publication_id = ? AND status != 'hidden'`, + [now, publicationId] + ); + }; + const getPostById = async (postId, { viewerId = null, includeHidden = false } = {}) => { + const statusClause = includeHidden ? "" : `AND pp.status = 'published'`; + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.id = ? ${statusClause} + LIMIT 1`, + [postId] + ); + const row = rows[0]; + if (!row) throw plazaError("\u5E16\u5B50\u4E0D\u5B58\u5728\u6216\u5DF2\u9690\u85CF", "POST_NOT_FOUND"); + let viewerReacted = null; + if (viewerId && loadViewerReactions) { + const map = await loadViewerReactions(viewerId, [postId]); + viewerReacted = map.get(postId) ?? { liked: false, collected: false }; + } + return formatPostRow(row, { viewerReacted, publicationUrl: row.public_url }); + }; + const listFeed = async ({ + sort = "recommend", + categorySlug = null, + cursor = null, + limit = 20, + viewerId = null, + sessionId = null + } = {}) => { + await ensureCategories(); + const normalizedSort = normalizeSort(sort); + const pageLimit = clampLimit(limit); + if (normalizedSort === "recommend" && recommendService) { + return recommendService.listRecommendedFeed({ + viewerId, + sessionId, + categorySlug, + cursor, + limit: pageLimit + }); + } + if (!cursor && normalizedSort !== "recommend" && plazaRedis2?.getFeedCache) { + const cached = await plazaRedis2.getFeedCache(normalizedSort, categorySlug, null); + if (cached?.posts) { + if (viewerId && loadViewerReactions) { + const reactionMap2 = await loadViewerReactions( + viewerId, + cached.posts.map((post) => post.id) + ); + cached.posts = cached.posts.map((post) => ({ + ...post, + viewer_reacted: reactionMap2.get(post.id) ?? null + })); + } + return cached; + } + } + const params = ["published"]; + let categoryFilter = ""; + if (categorySlug) { + categoryFilter = "AND c.slug = ?"; + params.push(categorySlug); + } + let cursorClause = ""; + if (cursor) { + const [cursorRows] = await pool.query( + `SELECT id, hot_score, published_at FROM plaza_posts WHERE id = ? LIMIT 1`, + [cursor] + ); + const cursorRow = cursorRows[0]; + if (cursorRow) { + if (normalizedSort === "new") { + cursorClause = "AND (pp.published_at < ? OR (pp.published_at = ? AND pp.id < ?))"; + params.push(cursorRow.published_at, cursorRow.published_at, cursor); + } else { + cursorClause = "AND (pp.hot_score < ? OR (pp.hot_score = ? AND pp.id < ?))"; + params.push(cursorRow.hot_score, cursorRow.hot_score, cursor); + } + } + } + const orderBy = normalizedSort === "new" ? "pp.published_at DESC, pp.id DESC" : "pp.hot_score DESC, pp.id DESC"; + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? + ${categoryFilter} + ${cursorClause} + ORDER BY ${orderBy} + LIMIT ?`, + [...params, pageLimit + 1] + ); + const hasMore = rows.length > pageLimit; + const pageRows = hasMore ? rows.slice(0, pageLimit) : rows; + let reactionMap = /* @__PURE__ */ new Map(); + if (viewerId && loadViewerReactions) { + reactionMap = await loadViewerReactions( + viewerId, + pageRows.map((row) => row.id) + ); + } + const posts = pageRows.map( + (row) => formatPostRow(row, { + viewerReacted: viewerId ? reactionMap.get(row.id) ?? null : null + }) + ); + let featured = { homepage_banner: [], trending: [], category_top: {} }; + if (!cursor && loadFeaturedPosts) { + featured = await loadFeaturedPosts(viewerId); + } + let mergedPosts = posts; + if (!cursor && normalizedSort === "hot" && featured.trending.length > 0) { + const trending = featured.trending.slice(0, 10); + const trendingIds = new Set(trending.map((post) => post.id)); + mergedPosts = [...trending, ...posts.filter((post) => !trendingIds.has(post.id))].slice( + 0, + pageLimit + ); + } + if (!cursor && normalizedSort === "hot" && categorySlug && featured.category_top?.[categorySlug]?.length) { + const tops = featured.category_top[categorySlug].slice(0, 3); + const topIds = new Set(tops.map((post) => post.id)); + mergedPosts = [...tops, ...mergedPosts.filter((post) => !topIds.has(post.id))].slice( + 0, + pageLimit + ); + } + if (!cursor && normalizedSort === "hot") { + mergedPosts = promoteFreshPosts(mergedPosts).slice(0, pageLimit); + } + const result = { + posts: mergedPosts, + featured: { homepage_banner: featured.homepage_banner, trending: [] }, + next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null, + has_more: hasMore + }; + if (!cursor && plazaRedis2?.setFeedCache) { + const ttl = normalizedSort === "new" ? 60 : 300; + await plazaRedis2.setFeedCache( + normalizedSort, + categorySlug, + null, + { + posts: mergedPosts, + featured: result.featured, + next_cursor: result.next_cursor, + has_more: result.has_more + }, + ttl + ); + } + return result; + }; + const reviewPost = async (postId, action, { reason = null } = {}) => { + const now = Date.now(); + if (action === "approve") { + const hotScore = await resolveHotScore(now); + await pool.query( + `UPDATE plaza_posts + SET status = 'published', hot_score = ?, hot_updated_at = ?, updated_at = ? + WHERE id = ?`, + [hotScore, now, now, postId] + ); + await plazaRedis2?.invalidateFeedCaches?.(); + await onPostPublished?.(postId); + return { id: postId, status: "published" }; + } + if (action === "reject") { + if (!reason) throw plazaError("\u62D2\u7EDD\u65F6\u5FC5\u987B\u586B\u5199\u539F\u56E0", "invalid_input"); + await pool.query( + `UPDATE plaza_posts SET status = 'rejected', updated_at = ? WHERE id = ?`, + [now, postId] + ); + return { id: postId, status: "rejected", reason }; + } + if (action === "hide") { + await pool.query( + `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ?`, + [now, postId] + ); + return { id: postId, status: "hidden" }; + } + throw plazaError("\u4E0D\u652F\u6301\u7684\u5BA1\u6838\u64CD\u4F5C", "invalid_input"); + }; + const listPendingPosts = async (limit = 50) => { + const [rows] = await pool.query( + `SELECT id, title, status, user_display_name, user_slug, published_at + FROM plaza_posts + WHERE status = 'pending_review' + ORDER BY published_at DESC + LIMIT ?`, + [clampLimit(limit, 50, 100)] + ); + return rows.map((row) => ({ + id: row.id, + title: row.title, + status: row.status, + author: row.user_display_name, + author_slug: row.user_slug, + published_at: new Date(Number(row.published_at)).toISOString() + })); + }; + return { + ensureCategories, + listCategories, + createPost, + updatePost, + hidePost, + hidePostsByPublicationId, + getPostById, + listFeed, + reviewPost, + listPendingPosts + }; +} +function mapPlazaError(error) { + const code = error?.code; + const map = { + PUBLICATION_NOT_ONLINE: 422, + ALREADY_PUBLISHED: 409, + POST_NOT_FOUND: 404, + POST_PERMISSION_DENIED: 403, + publication_not_found: 403, + category_not_found: 422, + category_required: 422, + invalid_input: 422, + COMMENT_DISABLED: 422, + COMMENT_TOO_LONG: 422, + COMMENT_RATE_LIMITED: 429, + REPLY_DEPTH_EXCEEDED: 422, + SELF_FOLLOW: 422, + COMMENT_NOT_FOUND: 404, + OPS_PERMISSION_DENIED: 403, + report_not_found: 404, + featured_not_found: 404, + user_not_found: 404 + }; + return map[code] ?? 500; +} + +// plaza-events.mjs +import crypto14 from "node:crypto"; +var PROFILE_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3; +var PROFILE_CACHE_TTL_MS = 45e3; +var PLAZA_EVENT_TYPES = /* @__PURE__ */ new Set([ + "impression", + "click", + "view", + "dwell", + "like", + "collect", + "comment", + "share", + "dislike", + "hide" +]); +var PLAZA_EVENT_WEIGHTS = { + impression: 0.06, + click: 0.85, + view: 1.25, + dwell: 1, + like: 5.5, + collect: 6.5, + comment: 4.5, + share: 3.5, + dislike: -9, + hide: -6 +}; +var DWELL_THRESHOLDS = [ + { ms: 3e4, boost: 4 }, + { ms: 1e4, boost: 2.5 }, + { ms: 3e3, boost: 1.5 } +]; +function plazaError2(message, code) { + return Object.assign(new Error(message), { code }); +} +var DEEP_SEEN_EVENTS = /* @__PURE__ */ new Set([ + "click", + "view", + "dwell", + "like", + "collect", + "comment", + "share" +]); +function emptyProfile() { + return { + profileVersion: 1, + categoryWeights: {}, + tagWeights: {}, + authorWeights: {}, + feedCategoryWeights: {}, + seenPostIds: /* @__PURE__ */ new Set(), + deepSeenPostIds: /* @__PURE__ */ new Set(), + dislikedPostIds: /* @__PURE__ */ new Set(), + dislikedCategorySlugs: /* @__PURE__ */ new Set(), + likedPostIds: /* @__PURE__ */ new Set(), + followedAuthorIds: /* @__PURE__ */ new Set(), + eventCount: 0 + }; +} +function decayFactor(ageMs, halfLifeMs) { + if (ageMs <= 0) return 1; + return Math.pow(0.5, ageMs / halfLifeMs); +} +function bumpWeight(map, key, delta) { + if (!key) return; + map[key] = (map[key] ?? 0) + delta; +} +function normalizeWeights(map) { + const entries = Object.entries(map); + if (entries.length === 0) return map; + const max = Math.max(...entries.map(([, value]) => value), 1e-6); + return Object.fromEntries(entries.map(([key, value]) => [key, value / max])); +} +function dwellBoost(dwellMs) { + const ms = Number(dwellMs ?? 0); + if (!Number.isFinite(ms) || ms <= 0) return 0; + for (const tier of DWELL_THRESHOLDS) { + if (ms >= tier.ms) return tier.boost; + } + return 0; +} +function eventSignal(event) { + let weight = PLAZA_EVENT_WEIGHTS[event.event_type] ?? 0; + if (event.event_type === "dwell") { + weight *= 1 + dwellBoost(event.dwell_ms); + } + return weight; +} +function createPlazaEventService(pool, { idFactory = () => crypto14.randomUUID() } = {}) { + const profileCache = /* @__PURE__ */ new Map(); + const cacheKey = ({ userId, sessionId }) => userId ? `u:${userId}` : sessionId ? `s:${sessionId}` : "anon"; + const invalidateProfileCache = ({ userId, sessionId }) => { + profileCache.delete(cacheKey({ userId, sessionId })); + if (userId) profileCache.delete(`u:${userId}`); + if (sessionId) profileCache.delete(`s:${sessionId}`); + }; + const recordEvents = async ({ + userId = null, + sessionId, + events = [] + }) => { + if (!sessionId) throw plazaError2("session_id \u4E0D\u80FD\u4E3A\u7A7A", "invalid_input"); + const now = Date.now(); + const normalized = []; + for (const raw of events) { + const eventType = String(raw?.event_type ?? raw?.type ?? "").trim(); + if (!PLAZA_EVENT_TYPES.has(eventType)) continue; + const postId = String(raw?.post_id ?? "").trim(); + if (!postId) continue; + normalized.push({ + id: idFactory(), + userId, + sessionId, + postId, + eventType, + dwellMs: raw?.dwell_ms == null || raw?.dwell_ms === "" ? null : Math.max(0, Math.min(36e5, Number(raw.dwell_ms))), + feedSort: String(raw?.feed_sort ?? "").slice(0, 32), + feedCategory: String(raw?.feed_category ?? "").slice(0, 64), + position: raw?.position == null || raw?.position === "" ? null : Math.max(0, Math.floor(Number(raw.position))), + createdAt: Number(raw?.created_at) > 0 ? Number(raw.created_at) : now + }); + } + if (normalized.length === 0) { + return { accepted: 0 }; + } + const values = normalized.map((event) => [ + event.id, + event.userId, + event.sessionId, + event.postId, + event.eventType, + event.dwellMs, + event.feedSort, + event.feedCategory, + event.position, + event.createdAt + ]); + await pool.query( + `INSERT INTO plaza_user_events + (id, user_id, session_id, post_id, event_type, dwell_ms, feed_sort, feed_category, position, created_at) + VALUES ?`, + [values] + ); + invalidateProfileCache({ userId, sessionId }); + return { accepted: normalized.length }; + }; + const buildProfileFromSignals = async ({ userId, sessionId, halfLifeMs }) => { + const profile = emptyProfile(); + const since = Date.now() - PROFILE_WINDOW_MS; + const params = [since]; + let identityClause = ""; + if (userId) { + identityClause = "AND (e.user_id = ? OR e.session_id = ?)"; + params.push(userId, sessionId); + } else { + identityClause = "AND e.session_id = ?"; + params.push(sessionId); + } + const [eventRows] = await pool.query( + `SELECT e.event_type, e.dwell_ms, e.post_id, e.created_at, + e.feed_category, c.slug AS category_slug, pp.tags, pp.user_id AS author_id + FROM plaza_user_events e + JOIN plaza_posts pp ON pp.id = e.post_id + JOIN plaza_categories c ON c.id = pp.category_id + WHERE e.created_at >= ? ${identityClause}`, + params + ); + const now = Date.now(); + for (const row of eventRows) { + profile.eventCount += 1; + profile.seenPostIds.add(row.post_id); + if (DEEP_SEEN_EVENTS.has(row.event_type)) { + profile.deepSeenPostIds.add(row.post_id); + } + const ageMs = now - Number(row.created_at); + const decay = decayFactor(ageMs, halfLifeMs); + const signal = eventSignal({ event_type: row.event_type, dwell_ms: row.dwell_ms }) * decay; + const feedCategory = String(row.feed_category ?? "").trim(); + if (feedCategory && (row.event_type === "impression" || row.event_type === "click")) { + bumpWeight(profile.feedCategoryWeights, feedCategory, Math.max(signal, 0.12) * decay); + } + if (row.event_type === "dislike" || row.event_type === "hide") { + profile.dislikedPostIds.add(row.post_id); + profile.dislikedCategorySlugs.add(row.category_slug); + bumpWeight(profile.categoryWeights, row.category_slug, signal * 0.35); + continue; + } + bumpWeight(profile.categoryWeights, row.category_slug, signal); + const tags = Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? "[]"); + for (const tag of tags) bumpWeight(profile.tagWeights, String(tag).toLowerCase(), signal * 0.65); + bumpWeight(profile.authorWeights, row.author_id, signal * 0.8); + if (row.event_type === "like") profile.likedPostIds.add(row.post_id); + if (row.event_type === "collect") profile.likedPostIds.add(row.post_id); + } + if (userId) { + const [reactions] = await pool.query( + `SELECT r.type, r.post_id, c.slug AS category_slug, pp.tags, pp.user_id AS author_id, r.created_at + FROM plaza_reactions r + JOIN plaza_posts pp ON pp.id = r.post_id AND pp.status = 'published' + JOIN plaza_categories c ON c.id = pp.category_id + WHERE r.user_id = ? AND r.created_at >= ?`, + [userId, since] + ); + for (const row of reactions) { + const ageMs = now - Number(row.created_at); + const decay = decayFactor(ageMs, halfLifeMs); + const signal = (row.type === "collect" ? 6.5 : row.type === "share" ? 3.5 : 5.5) * decay; + bumpWeight(profile.categoryWeights, row.category_slug, signal); + const tags = Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? "[]"); + for (const tag of tags) bumpWeight(profile.tagWeights, String(tag).toLowerCase(), signal * 0.65); + bumpWeight(profile.authorWeights, row.author_id, signal); + profile.likedPostIds.add(row.post_id); + profile.seenPostIds.add(row.post_id); + } + const [follows] = await pool.query( + `SELECT followee_id FROM plaza_follows WHERE follower_id = ?`, + [userId] + ); + for (const row of follows) { + profile.followedAuthorIds.add(row.followee_id); + bumpWeight(profile.authorWeights, row.followee_id, 4); + } + const [comments] = await pool.query( + `SELECT c.post_id, cat.slug AS category_slug, pp.tags, pp.user_id AS author_id, c.created_at + FROM plaza_comments c + JOIN plaza_posts pp ON pp.id = c.post_id AND pp.status = 'published' + JOIN plaza_categories cat ON cat.id = pp.category_id + WHERE c.user_id = ? AND c.status = 'visible' AND c.created_at >= ?`, + [userId, since] + ); + for (const row of comments) { + const ageMs = now - Number(row.created_at); + const decay = decayFactor(ageMs, halfLifeMs); + const signal = 4.5 * decay; + bumpWeight(profile.categoryWeights, row.category_slug, signal); + const tags = Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? "[]"); + for (const tag of tags) bumpWeight(profile.tagWeights, String(tag).toLowerCase(), signal * 0.65); + bumpWeight(profile.authorWeights, row.author_id, signal * 0.85); + profile.seenPostIds.add(row.post_id); + } + } + profile.categoryWeights = normalizeWeights(profile.categoryWeights); + profile.tagWeights = normalizeWeights(profile.tagWeights); + profile.authorWeights = normalizeWeights(profile.authorWeights); + profile.feedCategoryWeights = normalizeWeights(profile.feedCategoryWeights); + profile.profileVersion = 1 + profile.eventCount + profile.likedPostIds.size * 3 + profile.dislikedPostIds.size * 5; + return profile; + }; + const getProfile = async ({ userId = null, sessionId, halfLifeMs = 7 * 24 * 60 * 60 * 1e3 }) => { + if (!userId && !sessionId) return emptyProfile(); + const key = cacheKey({ userId, sessionId }); + const cached = profileCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.profile; + } + const profile = await buildProfileFromSignals({ userId, sessionId, halfLifeMs }); + profileCache.set(key, { profile, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS }); + return profile; + }; + return { + recordEvents, + getProfile, + invalidateProfileCache, + internals: { + emptyProfile, + decayFactor, + eventSignal, + bumpWeight, + normalizeWeights, + PLAZA_EVENT_WEIGHTS + } + }; +} + +// plaza-recommend.mjs +var RECALL_LIMIT = 80; +var CANDIDATE_POOL_LIMIT = 320; +var RANK_CACHE_TTL_MS = 18e4; +var RANK_CACHE_MAX = 240; +var DEFAULT_RECOMMEND_CONFIG = { + w_category: 0.26, + w_tag: 0.18, + w_author: 0.15, + w_browse: 0.08, + w_hot: 0.11, + w_fresh: 0.1, + w_quality: 0.07, + w_ctr: 0.05, + w_seen_penalty: 0.38, + w_impression_penalty: 0.14, + w_dislike_penalty: 1.35, + w_category_dislike: 0.55, + mmr_lambda: 0.72, + profile_half_life_days: 7, + explore_ratio: 0.1, + follow_boost: 0.35, + channel_boost_cap: 0.24 +}; +var CHANNEL_WEIGHTS = { + interest: 1, + follow: 0.95, + similar: 0.88, + tag: 0.92, + tag_similar: 0.86, + fresh: 0.82, + hot: 0.65, + explore: 0.55 +}; +var rankSessionCache = /* @__PURE__ */ new Map(); +function parseTags(raw) { + if (Array.isArray(raw)) return raw.map((tag) => String(tag).toLowerCase()); + try { + return JSON.parse(raw ?? "[]").map((tag) => String(tag).toLowerCase()); + } catch { + return []; + } +} +function parseRecommendCursor(cursor) { + if (!cursor) return { offset: 0, profileVersion: null }; + try { + const parsed = JSON.parse(Buffer.from(String(cursor), "base64url").toString("utf8")); + return { + offset: Math.max(0, Number(parsed.o) || 0), + profileVersion: parsed.p == null ? null : Number(parsed.p) + }; + } catch { + return { offset: 0, profileVersion: null }; + } +} +function encodeRecommendCursor({ offset, profileVersion }) { + return Buffer.from(JSON.stringify({ o: offset, p: profileVersion }), "utf8").toString("base64url"); +} +function jaccard(a, b) { + const left = new Set(a); + const right = new Set(b); + if (left.size === 0 || right.size === 0) return 0; + let inter = 0; + for (const item of left) { + if (right.has(item)) inter += 1; + } + const union = left.size + right.size - inter; + return union > 0 ? inter / union : 0; +} +function freshnessScore(publishedAt, now = Date.now()) { + const ageHours = Math.max(0, (now - publishedAt) / 36e5); + return Math.exp(-ageHours / 72); +} +function qualityScore(row) { + const views = Number(row.view_count ?? 0); + const likes = Number(row.like_count ?? 0); + const comments = Number(row.comment_count ?? 0); + return Math.log1p(views) * 0.35 + Math.log1p(likes) * 0.45 + Math.log1p(comments) * 0.2; +} +function ctrPrior(row) { + const views = Math.max(1, Number(row.view_count ?? 0)); + const likes = Number(row.like_count ?? 0); + const collects = Number(row.collect_count ?? 0); + return (likes * 2 + collects * 3) / views; +} +function itemSimilarity(a, b) { + if (a.category_slug === b.category_slug) return 1; + if (a.author_id === b.author_id) return 0.82; + return jaccard(a.tags, b.tags) * 0.75; +} +function mmrRerank(items, { lambda, limit }) { + const selected = []; + const remaining = [...items]; + while (selected.length < limit && remaining.length > 0) { + let bestIndex = 0; + let bestScore = -Infinity; + for (let index = 0; index < remaining.length; index += 1) { + const candidate = remaining[index]; + let maxSimilarity = 0; + for (const chosen of selected) { + maxSimilarity = Math.max(maxSimilarity, itemSimilarity(candidate, chosen)); + } + const mmrScore = candidate.rank_score - lambda * maxSimilarity; + if (mmrScore > bestScore) { + bestScore = mmrScore; + bestIndex = index; + } + } + selected.push(remaining.splice(bestIndex, 1)[0]); + } + return selected; +} +function topWeightedKeys(weightMap, limit = 5) { + return Object.entries(weightMap ?? {}).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([key]) => key); +} +function hashSeed(value) { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} +function seededShuffle(items, seed) { + const list = [...items]; + let state = hashSeed(String(seed)); + for (let index = list.length - 1; index > 0; index -= 1) { + state = Math.imul(state, 1664525) + 1013904223 >>> 0; + const swapIndex = state % (index + 1); + [list[index], list[swapIndex]] = [list[swapIndex], list[index]]; + } + return list; +} +function coldStartInterleave(candidates, sessionSeed = "anon") { + const groups = /* @__PURE__ */ new Map(); + for (const candidate of candidates) { + const key = candidate.category_slug || "other"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(candidate); + } + for (const list of groups.values()) { + list.sort((a, b) => b.hot_score - a.hot_score || b.published_at - a.published_at); + } + const categories = seededShuffle([...groups.keys()], sessionSeed); + const ordered = []; + let progress = true; + while (progress && ordered.length < candidates.length) { + progress = false; + for (const category of categories) { + const list = groups.get(category); + if (!list || list.length === 0) continue; + ordered.push(list.shift()); + progress = true; + } + } + return ordered.map((candidate, index) => ({ + ...candidate, + rank_score: 1 - index * 8e-4 + })); +} +function rankCacheKey(viewerId, sessionId, categorySlug, profileVersion) { + return `${viewerId || sessionId || "anon"}:${categorySlug || "all"}:${profileVersion}`; +} +function getRankCache(key) { + const cached = rankSessionCache.get(key); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + rankSessionCache.delete(key); + return null; + } + return cached; +} +function setRankCache(key, orderedItems) { + if (rankSessionCache.size >= RANK_CACHE_MAX) { + const oldestKey = rankSessionCache.keys().next().value; + if (oldestKey) rankSessionCache.delete(oldestKey); + } + rankSessionCache.set(key, { + orderedItems, + expiresAt: Date.now() + RANK_CACHE_TTL_MS + }); +} +function buildTagMatchClause(tags, params) { + if (tags.length === 0) return { clause: " AND 1 = 0", params }; + const parts = tags.map((tag) => { + params.push(JSON.stringify(tag)); + return 'JSON_CONTAINS(pp.tags, ?, "$")'; + }); + return { clause: ` AND (${parts.join(" OR ")})`, params }; +} +function createPlazaRecommendService(pool, { + eventService, + formatPostRow: formatPostRow2, + loadViewerReactions = null, + algorithmConfig = null, + config: configOverride = null +}) { + const config = { ...DEFAULT_RECOMMEND_CONFIG, ...configOverride }; + const rowToCandidate = (row) => ({ + id: row.id, + category_slug: row.category_slug, + category_name: row.category_name, + category_icon: row.category_icon, + author_id: row.user_id, + tags: parseTags(row.tags), + hot_score: Number(row.hot_score ?? 0), + published_at: Number(row.published_at), + view_count: Number(row.view_count ?? 0), + like_count: Number(row.like_count ?? 0), + collect_count: Number(row.collect_count ?? 0), + comment_count: Number(row.comment_count ?? 0), + row + }); + const loadPublishedRows = async ({ + categorySlug = null, + limit = CANDIDATE_POOL_LIMIT, + orderBy = "pp.hot_score DESC, pp.id DESC" + }) => { + const params = ["published"]; + let filter = ""; + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } + params.push(limit); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? ${filter} + ORDER BY ${orderBy} + LIMIT ?`, + params + ); + return rows; + }; + const scoreCandidate = (candidate, profile, now, maxHotScore, categorySlug) => { + if (profile.dislikedPostIds.has(candidate.id)) { + return { ...candidate, rank_score: -999, features: { filtered: true } }; + } + if (profile.dislikedCategorySlugs.has(candidate.category_slug)) { + return { ...candidate, rank_score: -999, features: { filtered: true } }; + } + const categoryAffinity = profile.categoryWeights[candidate.category_slug] ?? 0; + const tagAffinity = jaccard(candidate.tags, topWeightedKeys(profile.tagWeights, 20)); + const authorAffinity = profile.authorWeights[candidate.author_id] ?? 0; + const browseAffinity = !categorySlug && profile.feedCategoryWeights ? profile.feedCategoryWeights[candidate.category_slug] ?? 0 : 0; + const followBoost = profile.followedAuthorIds.has(candidate.author_id) ? config.follow_boost : 0; + const hot = candidate.hot_score / Math.max(maxHotScore, 1); + const fresh = freshnessScore(candidate.published_at, now); + const quality = qualityScore(candidate); + const ctr = ctrPrior(candidate); + const seenPenalty = profile.deepSeenPostIds.has(candidate.id) ? config.w_seen_penalty : profile.seenPostIds.has(candidate.id) ? config.w_impression_penalty : 0; + const dislikePenalty = profile.dislikedPostIds.has(candidate.id) ? config.w_dislike_penalty : 0; + const categoryDislikePenalty = profile.dislikedCategorySlugs.has(candidate.category_slug) ? config.w_category_dislike : 0; + const channelBoost = Math.min( + config.channel_boost_cap, + (candidate.recall_channels ?? []).reduce( + (sum, channel) => sum + (CHANNEL_WEIGHTS[channel] ?? 0) * 0.05, + 0 + ) + ); + const rankScore = config.w_category * categoryAffinity + config.w_tag * tagAffinity + config.w_author * (authorAffinity + followBoost) + config.w_browse * browseAffinity + config.w_hot * hot + config.w_fresh * fresh + config.w_quality * quality + config.w_ctr * ctr + channelBoost - seenPenalty - dislikePenalty - categoryDislikePenalty; + return { + ...candidate, + rank_score: rankScore, + features: { + category_affinity: categoryAffinity, + tag_affinity: tagAffinity, + author_affinity: authorAffinity, + browse_affinity: browseAffinity, + follow_boost: followBoost, + hot, + fresh, + quality, + ctr, + seen_penalty: seenPenalty, + dislike_penalty: dislikePenalty, + category_dislike_penalty: categoryDislikePenalty, + channel_boost: channelBoost + } + }; + }; + const mergeRecall = (target, rows, channel) => { + for (const row of rows) { + const candidate = rowToCandidate(row); + const existing = target.get(candidate.id); + if (existing) { + existing.recall_channels = [.../* @__PURE__ */ new Set([...existing.recall_channels ?? [], channel])]; + existing.recall_score = Math.max(existing.recall_score ?? 0, CHANNEL_WEIGHTS[channel] ?? 0); + continue; + } + target.set(candidate.id, { + ...candidate, + recall_channels: [channel], + recall_score: CHANNEL_WEIGHTS[channel] ?? 0 + }); + } + }; + const recallInterest = async (profile, categorySlug) => { + const categories = topWeightedKeys(profile.categoryWeights, 4); + if (categories.length === 0) return []; + const params = ["published", ...categories]; + let filter = ` AND c.slug IN (${categories.map(() => "?").join(",")})`; + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } + params.push(RECALL_LIMIT); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? ${filter} + ORDER BY pp.hot_score DESC, pp.published_at DESC + LIMIT ?`, + params + ); + return rows; + }; + const recallFollow = async (profile, categorySlug) => { + const authors = [...profile.followedAuthorIds]; + if (authors.length === 0) return []; + const params = ["published", ...authors]; + let filter = ` AND pp.user_id IN (${authors.map(() => "?").join(",")})`; + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } + params.push(RECALL_LIMIT); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? ${filter} + ORDER BY pp.published_at DESC + LIMIT ?`, + params + ); + return rows; + }; + const recallSimilar = async (profile, userId, categorySlug) => { + const seeds = [...profile.likedPostIds].slice(0, 12); + if (seeds.length === 0) return []; + const params = ["published", ...seeds]; + let filter = ` AND r1.post_id IN (${seeds.map(() => "?").join(",")})`; + if (userId) { + filter += " AND r2.user_id <> ?"; + params.push(userId); + } + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } + params.push(RECALL_LIMIT); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, + pr.public_url, + COUNT(*) AS overlap_score + FROM plaza_reactions r1 + JOIN plaza_reactions r2 + ON r2.user_id = r1.user_id + AND r2.type IN ('like', 'collect') + AND r2.post_id <> r1.post_id + JOIN plaza_posts pp ON pp.id = r2.post_id AND pp.status = 'published' + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE r1.type IN ('like', 'collect') ${filter} + GROUP BY pp.id, c.name, c.slug, c.icon + ORDER BY overlap_score DESC, pp.hot_score DESC + LIMIT ?`, + params + ); + return rows; + }; + const recallFresh = async (profile, categorySlug) => { + const categories = topWeightedKeys(profile.categoryWeights, 3); + const params = ["published"]; + let filter = ""; + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } else if (categories.length > 0) { + filter += ` AND c.slug IN (${categories.map(() => "?").join(",")})`; + params.push(...categories); + } + params.push(RECALL_LIMIT); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? ${filter} + ORDER BY pp.published_at DESC + LIMIT ?`, + params + ); + return rows; + }; + const recallExplore = async (profile, categorySlug) => { + const dominant = new Set(topWeightedKeys(profile.categoryWeights, 2)); + const params = ["published"]; + let filter = ""; + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } else if (dominant.size > 0) { + filter += ` AND c.slug NOT IN (${[...dominant].map(() => "?").join(",")})`; + params.push(...dominant); + } + params.push(Math.max(12, Math.floor(RECALL_LIMIT * config.explore_ratio))); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? ${filter} + ORDER BY pp.published_at DESC, pp.hot_score DESC + LIMIT ?`, + params + ); + return rows; + }; + const recallByTags = async (profile, categorySlug) => { + const tags = topWeightedKeys(profile.tagWeights, 6); + if (tags.length === 0) return []; + const params = ["published"]; + let filter = ""; + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } + const tagMatch = buildTagMatchClause(tags, params); + filter += tagMatch.clause; + params.push(RECALL_LIMIT); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? ${filter} + ORDER BY pp.hot_score DESC, pp.published_at DESC + LIMIT ?`, + params + ); + return rows; + }; + const recallContentSimilar = async (profile, categorySlug) => { + const seeds = [...profile.likedPostIds].slice(0, 8); + if (seeds.length === 0) return []; + const [seedRows] = await pool.query( + `SELECT tags FROM plaza_posts WHERE id IN (${seeds.map(() => "?").join(",")}) AND status = 'published'`, + seeds + ); + const tagSet = /* @__PURE__ */ new Set(); + for (const row of seedRows) { + for (const tag of parseTags(row.tags)) tagSet.add(tag); + } + const tags = [...tagSet].slice(0, 10); + if (tags.length === 0) return []; + const params = ["published"]; + let filter = ""; + if (categorySlug) { + filter += " AND c.slug = ?"; + params.push(categorySlug); + } + if (seeds.length > 0) { + filter += ` AND pp.id NOT IN (${seeds.map(() => "?").join(",")})`; + params.push(...seeds); + } + const tagMatch = buildTagMatchClause(tags, params); + filter += tagMatch.clause; + params.push(RECALL_LIMIT); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pp.status = ? ${filter} + ORDER BY pp.published_at DESC, pp.hot_score DESC + LIMIT ?`, + params + ); + return rows; + }; + const recallHotFallback = async (categorySlug) => loadPublishedRows({ + categorySlug, + limit: RECALL_LIMIT, + orderBy: "pp.hot_score DESC, pp.id DESC" + }); + const buildRecallPool = async ({ profile, viewerId, categorySlug }) => { + const poolMap = /* @__PURE__ */ new Map(); + const [ + interestRows, + followRows, + similarRows, + tagRows, + tagSimilarRows, + freshRows, + exploreRows, + hotRows + ] = await Promise.all([ + recallInterest(profile, categorySlug), + recallFollow(profile, categorySlug), + recallSimilar(profile, viewerId, categorySlug), + recallByTags(profile, categorySlug), + recallContentSimilar(profile, categorySlug), + recallFresh(profile, categorySlug), + recallExplore(profile, categorySlug), + recallHotFallback(categorySlug) + ]); + mergeRecall(poolMap, interestRows, "interest"); + mergeRecall(poolMap, followRows, "follow"); + mergeRecall(poolMap, similarRows, "similar"); + mergeRecall(poolMap, tagRows, "tag"); + mergeRecall(poolMap, tagSimilarRows, "tag_similar"); + mergeRecall(poolMap, freshRows, "fresh"); + mergeRecall(poolMap, exploreRows, "explore"); + mergeRecall(poolMap, hotRows, "hot"); + if (poolMap.size < 24) { + mergeRecall( + poolMap, + await loadPublishedRows({ categorySlug, limit: CANDIDATE_POOL_LIMIT }), + "hot" + ); + } + return poolMap; + }; + const listRecommendedFeed = async ({ + viewerId = null, + sessionId = null, + categorySlug = null, + cursor = null, + limit = 20 + } = {}) => { + const pageLimit = Math.min(Math.max(1, Number(limit) || 20), 50); + const halfLifeMs = Number(config.profile_half_life_days ?? 7) * 24 * 60 * 60 * 1e3; + const profile = await eventService.getProfile({ + userId: viewerId, + sessionId, + halfLifeMs + }); + const parsedCursor = parseRecommendCursor(cursor); + if (parsedCursor.profileVersion != null && parsedCursor.profileVersion !== profile.profileVersion && parsedCursor.offset > 0) { + parsedCursor.offset = 0; + } + const cacheKey = rankCacheKey(viewerId, sessionId, categorySlug, profile.profileVersion); + const isColdStart = profile.eventCount === 0 && profile.likedPostIds.size === 0; + let reranked = null; + if (parsedCursor.offset > 0) { + const cached = getRankCache(cacheKey); + if (cached) reranked = cached.orderedItems; + } + const recallPool = reranked ? null : await buildRecallPool({ + profile, + viewerId, + categorySlug + }); + const now = Date.now(); + const hotConfig = algorithmConfig ?? {}; + if (!reranked) { + const scored = [...recallPool.values()].map((candidate) => { + if (!candidate.hot_score) { + candidate.hot_score = computeHotScore(candidate.row, hotConfig, now); + } + return candidate; + }); + const maxHotScore = Math.max(...scored.map((candidate) => candidate.hot_score), 1); + let ranked; + if (isColdStart && !categorySlug) { + ranked = coldStartInterleave(scored, sessionId || viewerId || "anon"); + } else { + ranked = scored.map((candidate) => scoreCandidate(candidate, profile, now, maxHotScore, categorySlug)).filter((candidate) => candidate.rank_score > -100).sort((a, b) => b.rank_score - a.rank_score); + } + reranked = mmrRerank(ranked, { + lambda: config.mmr_lambda, + limit: Math.max(ranked.length, parsedCursor.offset + pageLimit + 24) + }); + setRankCache(cacheKey, reranked); + } + const pageItems = reranked.slice(parsedCursor.offset, parsedCursor.offset + pageLimit); + const hasMore = reranked.length > parsedCursor.offset + pageLimit; + let reactionMap = /* @__PURE__ */ new Map(); + if (viewerId && loadViewerReactions) { + reactionMap = await loadViewerReactions( + viewerId, + pageItems.map((item) => item.id) + ); + } + const posts = pageItems.map( + (item) => formatPostRow2(item.row, { + viewerReacted: viewerId ? reactionMap.get(item.id) ?? null : null + }) + ); + return { + posts, + featured: { homepage_banner: [], trending: [] }, + next_cursor: hasMore ? encodeRecommendCursor({ + offset: parsedCursor.offset + pageLimit, + profileVersion: profile.profileVersion + }) : null, + has_more: hasMore, + recommend_meta: { + profile_version: profile.profileVersion, + candidate_count: recallPool?.size ?? reranked.length, + event_count: profile.eventCount, + cold_start: isColdStart, + top_categories: topWeightedKeys(profile.categoryWeights, 3), + top_tags: topWeightedKeys(profile.tagWeights, 5), + session_cached: parsedCursor.offset > 0 + } + }; + }; + return { + listRecommendedFeed, + internals: { + parseRecommendCursor, + encodeRecommendCursor, + scoreCandidate, + mmrRerank, + jaccard, + itemSimilarity, + DEFAULT_RECOMMEND_CONFIG, + coldStartInterleave, + seededShuffle, + rankCacheKey + } + }; +} + +// plaza-interactions.mjs +import crypto15 from "node:crypto"; +var REACTION_TYPES = /* @__PURE__ */ new Set(["like", "collect", "share"]); +var COUNTER_BY_TYPE = { + like: "like_count", + collect: "collect_count", + share: "share_count" +}; +function plazaError3(message, code, details) { + return Object.assign(new Error(message), { code, details }); +} +function clampLimit2(value, fallback = 20, max = 50) { + const limit = Number(value ?? fallback); + if (!Number.isFinite(limit) || limit < 1) return fallback; + return Math.min(Math.floor(limit), max); +} +function normalizeReactionType(value) { + const type = String(value ?? "").trim(); + if (!REACTION_TYPES.has(type)) throw plazaError3("\u4E0D\u652F\u6301\u7684\u4E92\u52A8\u7C7B\u578B", "invalid_input"); + return type; +} +function formatCommentAuthor(row) { + return { + user_id: row.user_id, + slug: row.author_slug || row.slug || row.username, + display_name: row.author_display_name || row.display_name || row.username, + avatar_url: "" + }; +} +function createPlazaInteractionService(pool, { idFactory = () => crypto15.randomUUID(), formatPostRow: formatPostRow2, plazaRedis: plazaRedis2 = null } = {}) { + const requirePublishedPost = async (postId, conn = pool) => { + const [rows] = await conn.query( + `SELECT * FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`, + [postId] + ); + if (!rows[0]) throw plazaError3("\u5E16\u5B50\u4E0D\u5B58\u5728\u6216\u5DF2\u9690\u85CF", "POST_NOT_FOUND"); + return rows[0]; + }; + const loadViewerReactions = async (viewerId, postIds) => { + const map = /* @__PURE__ */ new Map(); + if (!viewerId || postIds.length === 0) return map; + const placeholders = postIds.map(() => "?").join(", "); + const [rows] = await pool.query( + `SELECT post_id, type FROM plaza_reactions + WHERE user_id = ? AND post_id IN (${placeholders})`, + [viewerId, ...postIds] + ); + for (const id of postIds) { + map.set(id, { liked: false, collected: false }); + } + for (const row of rows) { + const current = map.get(row.post_id) ?? { liked: false, collected: false }; + if (row.type === "like") current.liked = true; + if (row.type === "collect") current.collected = true; + map.set(row.post_id, current); + } + return map; + }; + const bumpCounter = async (conn, postId, field, delta) => { + const now = Date.now(); + if (delta > 0) { + await conn.query( + `UPDATE plaza_posts SET ${field} = ${field} + ?, updated_at = ? WHERE id = ?`, + [delta, now, postId] + ); + return; + } + await conn.query( + `UPDATE plaza_posts SET ${field} = GREATEST(CAST(${field} AS SIGNED) + ?, 0), updated_at = ? WHERE id = ?`, + [delta, now, postId] + ); + }; + const addReaction = async (userId, postId, typeInput) => { + const type = normalizeReactionType(typeInput); + const field = COUNTER_BY_TYPE[type]; + await requirePublishedPost(postId); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [existing] = await conn.query( + `SELECT id FROM plaza_reactions WHERE post_id = ? AND user_id = ? AND type = ? LIMIT 1`, + [postId, userId, type] + ); + if (!existing[0]) { + await conn.query( + `INSERT INTO plaza_reactions (id, post_id, user_id, type, created_at) VALUES (?, ?, ?, ?, ?)`, + [idFactory(), postId, userId, type, Date.now()] + ); + await bumpCounter(conn, postId, field, 1); + } + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + return { post_id: postId, type, active: true }; + }; + const removeReaction = async (userId, postId, typeInput) => { + const type = normalizeReactionType(typeInput); + const field = COUNTER_BY_TYPE[type]; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query( + `DELETE FROM plaza_reactions WHERE post_id = ? AND user_id = ? AND type = ?`, + [postId, userId, type] + ); + if (result.affectedRows > 0) { + await bumpCounter(conn, postId, field, -1); + } + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + return { post_id: postId, type, active: false }; + }; + const listComments = async (postId, { cursor = null, limit = 20, parentId = null, viewerId = null } = {}) => { + await requirePublishedPost(postId); + const pageLimit = clampLimit2(limit); + const params = [postId, "visible"]; + let parentClause = "AND c.parent_id IS NULL"; + if (parentId) { + parentClause = "AND c.parent_id = ?"; + params.push(parentId); + } + let cursorClause = ""; + if (cursor) { + cursorClause = "AND c.created_at < (SELECT created_at FROM plaza_comments WHERE id = ? LIMIT 1)"; + params.push(cursor); + } + params.push(pageLimit + 1); + const [rows] = await pool.query( + `SELECT c.*, u.slug AS author_slug, u.username, u.display_name AS author_display_name + FROM plaza_comments c + JOIN h5_users u ON u.id = c.user_id + WHERE c.post_id = ? AND c.status = ? ${parentClause} ${cursorClause} + ORDER BY c.created_at DESC + LIMIT ?`, + params + ); + const hasMore = rows.length > pageLimit; + const pageRows = hasMore ? rows.slice(0, pageLimit) : rows; + let likedSet = /* @__PURE__ */ new Set(); + if (viewerId && pageRows.length > 0) { + const ids = pageRows.map((row) => row.id); + const [likedRows] = await pool.query( + `SELECT comment_id FROM plaza_comment_reactions + WHERE user_id = ? AND comment_id IN (${ids.map(() => "?").join(", ")})`, + [viewerId, ...ids] + ); + likedSet = new Set(likedRows.map((row) => row.comment_id)); + } + const comments = pageRows.map((row) => ({ + id: row.id, + content: row.content, + author: formatCommentAuthor(row), + like_count: Number(row.like_count ?? 0), + reply_count: Number(row.reply_count ?? 0), + viewer_liked: viewerId ? likedSet.has(row.id) : false, + created_at: new Date(Number(row.created_at)).toISOString(), + status: row.status, + parent_id: row.parent_id + })); + return { + comments, + next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null, + has_more: hasMore + }; + }; + const createComment = async (userId, postId, { content, parent_id: parentId = null }) => { + const text = String(content ?? "").trim(); + if (!text) throw plazaError3("\u8BC4\u8BBA\u4E0D\u80FD\u4E3A\u7A7A", "invalid_input"); + if (text.length > 500) throw plazaError3("\u8BC4\u8BBA\u8D85\u51FA 500 \u5B57\u7B26", "COMMENT_TOO_LONG"); + const post = await requirePublishedPost(postId); + if (!post.allow_comment) throw plazaError3("\u8BE5\u5E16\u5B50\u5DF2\u5173\u95ED\u8BC4\u8BBA", "COMMENT_DISABLED"); + const [users] = await pool.query( + `SELECT plaza_comment_banned FROM h5_users WHERE id = ? LIMIT 1`, + [userId] + ); + if (users[0]?.plaza_comment_banned) { + throw plazaError3("\u65E0\u8BC4\u8BBA\u6743\u9650", "POST_PERMISSION_DENIED"); + } + if (plazaRedis2?.checkCommentRateLimit) { + const rate = await plazaRedis2.checkCommentRateLimit(userId, postId); + if (!rate.allowed) { + throw plazaError3("\u8BC4\u8BBA\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5", "COMMENT_RATE_LIMITED"); + } + } else { + const hourAgo = Date.now() - 36e5; + const [recent] = await pool.query( + `SELECT COUNT(*) AS count FROM plaza_comments + WHERE user_id = ? AND post_id = ? AND created_at >= ? AND status = 'visible'`, + [userId, postId, hourAgo] + ); + if (Number(recent[0]?.count ?? 0) >= 10) { + throw plazaError3("\u8BC4\u8BBA\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5", "COMMENT_RATE_LIMITED"); + } + } + let parentComment = null; + if (parentId) { + const [parents] = await pool.query( + `SELECT * FROM plaza_comments WHERE id = ? AND post_id = ? AND status = 'visible' LIMIT 1`, + [parentId, postId] + ); + parentComment = parents[0]; + if (!parentComment) throw plazaError3("\u7236\u8BC4\u8BBA\u4E0D\u5B58\u5728", "COMMENT_NOT_FOUND"); + if (parentComment.parent_id) { + throw plazaError3("\u4E0D\u5141\u8BB8\u4E09\u7EA7\u5D4C\u5957\u56DE\u590D", "REPLY_DEPTH_EXCEEDED"); + } + } + const now = Date.now(); + const commentId = idFactory(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO plaza_comments + (id, post_id, user_id, parent_id, content, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'visible', ?, ?)`, + [commentId, postId, userId, parentId, text, now, now] + ); + if (parentComment) { + await conn.query( + `UPDATE plaza_comments SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?`, + [now, parentId] + ); + } else { + await conn.query( + `UPDATE plaza_posts SET comment_count = comment_count + 1, updated_at = ? WHERE id = ?`, + [now, postId] + ); + } + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + await plazaRedis2?.markCommentRateLimit?.(userId, postId); + return { id: commentId }; + }; + const deleteComment = async (userId, commentId) => { + const [rows] = await pool.query( + `SELECT * FROM plaza_comments WHERE id = ? AND user_id = ? LIMIT 1`, + [commentId, userId] + ); + if (!rows[0]) throw plazaError3("\u8BC4\u8BBA\u4E0D\u5B58\u5728", "COMMENT_NOT_FOUND"); + const now = Date.now(); + await pool.query( + `UPDATE plaza_comments + SET content = '', status = 'deleted', deleted_by = 'user', updated_at = ? + WHERE id = ?`, + [now, commentId] + ); + return { id: commentId, status: "deleted" }; + }; + const toggleCommentLike = async (userId, commentId, liked) => { + const [rows] = await pool.query( + `SELECT c.* FROM plaza_comments c + JOIN plaza_posts p ON p.id = c.post_id + WHERE c.id = ? AND c.status = 'visible' AND p.status = 'published' + LIMIT 1`, + [commentId] + ); + if (!rows[0]) throw plazaError3("\u8BC4\u8BBA\u4E0D\u5B58\u5728", "COMMENT_NOT_FOUND"); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + if (liked) { + const [existing] = await conn.query( + `SELECT id FROM plaza_comment_reactions WHERE comment_id = ? AND user_id = ? LIMIT 1`, + [commentId, userId] + ); + if (!existing[0]) { + await conn.query( + `INSERT INTO plaza_comment_reactions (id, comment_id, user_id, created_at) VALUES (?, ?, ?, ?)`, + [idFactory(), commentId, userId, Date.now()] + ); + await conn.query( + `UPDATE plaza_comments SET like_count = like_count + 1, updated_at = ? WHERE id = ?`, + [Date.now(), commentId] + ); + } + } else { + const [result] = await conn.query( + `DELETE FROM plaza_comment_reactions WHERE comment_id = ? AND user_id = ?`, + [commentId, userId] + ); + if (result.affectedRows > 0) { + await conn.query( + `UPDATE plaza_comments SET like_count = GREATEST(CAST(like_count AS SIGNED) - 1, 0), updated_at = ? WHERE id = ?`, + [Date.now(), commentId] + ); + } + } + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + return { id: commentId, liked }; + }; + const resolveUserBySlug = async (slug) => { + const [rows] = await pool.query( + `SELECT id, slug, username, display_name, + plaza_post_count, plaza_follower_count, plaza_following_count + FROM h5_users WHERE slug = ? OR username = ? LIMIT 1`, + [slug, slug] + ); + const row = rows[0]; + if (!row) throw plazaError3("\u7528\u6237\u4E0D\u5B58\u5728", "user_not_found"); + return row; + }; + const followUser = async (followerId, followeeSlug) => { + const followee = await resolveUserBySlug(followeeSlug); + if (followee.id === followerId) throw plazaError3("\u4E0D\u80FD\u5173\u6CE8\u81EA\u5DF1", "SELF_FOLLOW"); + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query( + `INSERT IGNORE INTO plaza_follows (follower_id, followee_id, created_at) VALUES (?, ?, ?)`, + [followerId, followee.id, now] + ); + if (result.affectedRows > 0) { + await conn.query( + `UPDATE h5_users SET plaza_following_count = plaza_following_count + 1, updated_at = ? WHERE id = ?`, + [now, followerId] + ); + await conn.query( + `UPDATE h5_users SET plaza_follower_count = plaza_follower_count + 1, updated_at = ? WHERE id = ?`, + [now, followee.id] + ); + } + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + return { following: true }; + }; + const unfollowUser = async (followerId, followeeSlug) => { + const followee = await resolveUserBySlug(followeeSlug); + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query( + `DELETE FROM plaza_follows WHERE follower_id = ? AND followee_id = ?`, + [followerId, followee.id] + ); + if (result.affectedRows > 0) { + await conn.query( + `UPDATE h5_users SET plaza_following_count = GREATEST(CAST(plaza_following_count AS SIGNED) - 1, 0), updated_at = ? WHERE id = ?`, + [now, followerId] + ); + await conn.query( + `UPDATE h5_users SET plaza_follower_count = GREATEST(CAST(plaza_follower_count AS SIGNED) - 1, 0), updated_at = ? WHERE id = ?`, + [now, followee.id] + ); + } + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + return { following: false }; + }; + const getUserProfile = async (slug, viewerId = null) => { + const user = await resolveUserBySlug(slug); + const [likeRows] = await pool.query( + `SELECT COALESCE(SUM(like_count), 0) AS total_likes + FROM plaza_posts WHERE user_id = ? AND status = 'published'`, + [user.id] + ); + const [recentRows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + WHERE pp.user_id = ? AND pp.status = 'published' + ORDER BY pp.published_at DESC + LIMIT 6`, + [user.id] + ); + let viewerFollowing = false; + if (viewerId && viewerId !== user.id) { + const [followRows] = await pool.query( + `SELECT 1 FROM plaza_follows WHERE follower_id = ? AND followee_id = ? LIMIT 1`, + [viewerId, user.id] + ); + viewerFollowing = followRows.length > 0; + } + const recentPosts = recentRows.map( + (row) => formatPostRow2 ? formatPostRow2(row, { viewerReacted: null }) : row + ); + return { + user: { + user_id: user.id, + slug: user.slug || user.username, + display_name: user.display_name || user.username, + avatar_url: "", + bio: "", + stats: { + post_count: Number(user.plaza_post_count ?? 0), + follower_count: Number(user.plaza_follower_count ?? 0), + following_count: Number(user.plaza_following_count ?? 0), + total_likes: Number(likeRows[0]?.total_likes ?? 0) + }, + viewer_following: viewerId ? viewerFollowing : void 0 + }, + recent_posts: recentPosts + }; + }; + const listUserPosts = async (slug, { cursor = null, limit = 20, viewerId = null } = {}) => { + const user = await resolveUserBySlug(slug); + const pageLimit = clampLimit2(limit); + const params = [user.id, "published"]; + let cursorClause = ""; + if (cursor) { + cursorClause = "AND (pp.published_at < (SELECT published_at FROM plaza_posts WHERE id = ? LIMIT 1) OR (pp.published_at = (SELECT published_at FROM plaza_posts WHERE id = ? LIMIT 1) AND pp.id < ?))"; + params.push(cursor, cursor, cursor); + } + params.push(pageLimit + 1); + const [rows] = await pool.query( + `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + WHERE pp.user_id = ? AND pp.status = ? ${cursorClause} + ORDER BY pp.published_at DESC, pp.id DESC + LIMIT ?`, + params + ); + const hasMore = rows.length > pageLimit; + const pageRows = hasMore ? rows.slice(0, pageLimit) : rows; + const reactions = await loadViewerReactions( + viewerId, + pageRows.map((row) => row.id) + ); + const posts = pageRows.map( + (row) => formatPostRow2 ? formatPostRow2(row, { viewerReacted: viewerId ? reactions.get(row.id) ?? null : null }) : row + ); + return { + posts, + next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null, + has_more: hasMore + }; + }; + return { + loadViewerReactions, + addReaction, + removeReaction, + listComments, + createComment, + deleteComment, + toggleCommentLike, + followUser, + unfollowUser, + getUserProfile, + listUserPosts + }; +} + +// plaza-redis.mjs +import crypto16 from "node:crypto"; +var SYNC_SET = "plaza:sync:post_ids"; +var FEED_GEN_KEY = "plaza:feed:gen"; +function hashIp(ip) { + return crypto16.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 16); +} +function counterKey(postId, field) { + return `plaza:post:${postId}:${field}`; +} +function viewDedupKey(postId, ipHash) { + return `plaza:view:${ipHash}:${postId}`; +} +function feedCacheKey(gen, sort, categorySlug, cursor) { + const category = categorySlug || "all"; + const page = cursor || "root"; + return `plaza:feed:${sort}:${category}:cursor:${page}:g${gen}`; +} +function createNoopPlazaRedis(pool = null) { + const viewDedup = /* @__PURE__ */ new Map(); + return { + enabled: false, + async connect() { + }, + async disconnect() { + }, + async recordView(postId, ip) { + if (!pool) return { counted: false }; + const ipHash = hashIp(ip); + const dedupKey = `${ipHash}:${postId}`; + if (viewDedup.has(dedupKey)) return { counted: false }; + viewDedup.set(dedupKey, Date.now()); + setTimeout(() => viewDedup.delete(dedupKey), 864e5).unref?.(); + const now = Date.now(); + const [result] = await pool.query( + `UPDATE plaza_posts SET view_count = view_count + 1, updated_at = ? + WHERE id = ? AND status = 'published'`, + [now, postId] + ); + return { counted: (result.affectedRows ?? 0) > 0 }; + }, + async getFeedCache() { + return null; + }, + async setFeedCache() { + }, + async invalidateFeedCaches() { + }, + async syncCountersToMySQL() { + return { synced: 0 }; + }, + async checkCommentRateLimit() { + return { allowed: true }; + }, + async markCommentRateLimit() { + } + }; +} +async function createPlazaRedis(redisUrl, pool) { + if (!redisUrl) return createNoopPlazaRedis(pool); + const { createClient } = await import("redis"); + const client = createClient({ url: redisUrl }); + client.on("error", (error) => { + console.error("Plaza Redis error:", error.message); + }); + await client.connect(); + const getFeedGen = async () => Number(await client.get(FEED_GEN_KEY) ?? 0); + return { + enabled: true, + client, + async disconnect() { + if (client.isOpen) await client.disconnect(); + }, + async recordView(postId, ip) { + const ipHash = hashIp(ip); + const dedup = viewDedupKey(postId, ipHash); + const inserted = await client.set(dedup, "1", { NX: true, EX: 86400 }); + if (!inserted) return { counted: false }; + await client.incr(counterKey(postId, "view_count")); + await client.sAdd(SYNC_SET, postId); + return { counted: true }; + }, + async getFeedCache(sort, categorySlug, cursor) { + const gen = await getFeedGen(); + const key = feedCacheKey(gen, sort, categorySlug, cursor); + const raw = await client.get(key); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + }, + async setFeedCache(sort, categorySlug, cursor, payload, ttlSeconds = 300) { + const gen = await getFeedGen(); + const key = feedCacheKey(gen, sort, categorySlug, cursor); + await client.set(key, JSON.stringify(payload), { EX: ttlSeconds }); + }, + async invalidateFeedCaches() { + await client.incr(FEED_GEN_KEY); + }, + async syncCountersToMySQL() { + const postIds = await client.sMembers(SYNC_SET); + if (postIds.length === 0) return { synced: 0 }; + let synced = 0; + const now = Date.now(); + for (const postId of postIds) { + const delta = Number(await client.get(counterKey(postId, "view_count")) ?? 0); + if (delta <= 0) { + await client.sRem(SYNC_SET, postId); + continue; + } + await pool.query( + `UPDATE plaza_posts SET view_count = view_count + ?, updated_at = ? WHERE id = ?`, + [delta, now, postId] + ); + await client.decrBy(counterKey(postId, "view_count"), delta); + const remaining = Number(await client.get(counterKey(postId, "view_count")) ?? 0); + if (remaining <= 0) { + await client.del(counterKey(postId, "view_count")); + await client.sRem(SYNC_SET, postId); + } + synced += 1; + } + return { synced }; + }, + async checkCommentRateLimit(userId, postId, maxPerHour = 10) { + const key = `plaza:comment_rate:${userId}:${postId}`; + const count = Number(await client.get(key) ?? 0); + return { allowed: count < maxPerHour, count }; + }, + async markCommentRateLimit(userId, postId) { + const key = `plaza:comment_rate:${userId}:${postId}`; + const count = await client.incr(key); + if (count === 1) await client.expire(key, 3600); + return count; + } + }; +} + +// plaza-tasks.mjs +function startPlazaTasks({ + pool, + plazaRedis: plazaRedis2, + recalculateHotScores: recalculateHotScores2, + writebackPublications: writebackPublications2 +}) { + const timers = []; + const schedule = (fn, intervalMs, label) => { + const run = async () => { + try { + await fn(); + } catch (error) { + console.error(`Plaza task ${label} failed:`, error.message); + } + }; + void run(); + const timer = setInterval(run, intervalMs); + timer.unref?.(); + timers.push(timer); + }; + schedule(async () => { + const result = await recalculateHotScores2(pool); + if (result.updated > 0) await plazaRedis2.invalidateFeedCaches(); + }, 10 * 60 * 1e3, "hot_score"); + schedule(async () => { + await plazaRedis2.syncCountersToMySQL(); + }, 5 * 60 * 1e3, "redis_sync"); + schedule(async () => { + await writebackPublications2(pool); + }, 60 * 60 * 1e3, "publication_writeback"); + return { + stop() { + for (const timer of timers) clearInterval(timer); + } + }; +} +async function writebackPublications(pool) { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_publish_records p + JOIN plaza_posts pp ON pp.publication_id = p.id + SET p.plaza_view_count = pp.view_count, + p.plaza_like_count = pp.like_count + WHERE pp.status = 'published'` + ); + return { updated: result.affectedRows ?? 0, at: now }; +} + +// plaza-seo.mjs +import crypto17 from "node:crypto"; +function hashIp2(ip) { + return crypto17.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 32); +} +function normalizeUtm(value, maxLen = 100) { + return String(value ?? "").trim().slice(0, maxLen); +} +function normalizeEventType(value) { + const type = String(value ?? "").trim(); + if (type === "landing" || type === "signup") return type; + throw Object.assign(new Error("\u4E0D\u652F\u6301\u7684 attribution \u4E8B\u4EF6\u7C7B\u578B"), { code: "invalid_input" }); +} +function buildPostPublicUrl(postId, siteBase = process.env.PLAZA_PUBLIC_BASE ?? "https://plaza.tkmind.cn") { + const base = String(siteBase).replace(/\/$/, ""); + return `${base}/plaza/p/${postId}`; +} +function createPlazaSeoService(pool, { + idFactory = () => crypto17.randomUUID(), + siteBase = process.env.PLAZA_PUBLIC_BASE ?? "https://plaza.tkmind.cn", + baiduToken = process.env.PLAZA_BAIDU_PUSH_TOKEN ?? "", + baiduSite = process.env.PLAZA_BAIDU_SITE ?? "plaza.tkmind.cn" +} = {}) { + const listSitemapData = async ({ postLimit = 1e3, userLimit = 500 } = {}) => { + const [categoryRows] = await pool.query( + `SELECT slug, name FROM plaza_categories WHERE is_active = 1 ORDER BY sort_order ASC` + ); + const [postRows] = await pool.query( + `SELECT id, updated_at, published_at + FROM plaza_posts + WHERE status = 'published' + ORDER BY published_at DESC + LIMIT ?`, + [Math.min(Math.max(Number(postLimit) || 1e3, 1), 5e3)] + ); + const [userRows] = await pool.query( + `SELECT u.slug, u.username, MAX(pp.published_at) AS last_post_at + FROM h5_users u + JOIN plaza_posts pp ON pp.user_id = u.id AND pp.status = 'published' + GROUP BY u.id, u.slug, u.username + ORDER BY last_post_at DESC + LIMIT ?`, + [Math.min(Math.max(Number(userLimit) || 500, 1), 2e3)] + ); + return { + categories: categoryRows.map((row) => ({ + slug: row.slug, + name: row.name + })), + posts: postRows.map((row) => ({ + id: row.id, + updated_at: new Date(Number(row.updated_at ?? row.published_at)).toISOString(), + published_at: new Date(Number(row.published_at)).toISOString() + })), + users: userRows.map((row) => ({ + slug: row.slug || row.username, + last_post_at: row.last_post_at ? new Date(Number(row.last_post_at)).toISOString() : (/* @__PURE__ */ new Date()).toISOString() + })) + }; + }; + const recordAttribution = async ({ + event_type: eventTypeInput, + utm_source: utmSource, + utm_medium: utmMedium, + utm_campaign: utmCampaign, + ref_id: refId, + user_id: userId = null + }, ip = "unknown") => { + const eventType = normalizeEventType(eventTypeInput); + const source = normalizeUtm(utmSource); + if (!source) throw Object.assign(new Error("utm_source \u4E0D\u80FD\u4E3A\u7A7A"), { code: "invalid_input" }); + const id = idFactory(); + const now = Date.now(); + await pool.query( + `INSERT INTO plaza_attribution_events + (id, event_type, utm_source, utm_medium, utm_campaign, ref_id, user_id, ip_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + eventType, + source, + normalizeUtm(utmMedium), + normalizeUtm(utmCampaign), + normalizeUtm(refId, 200), + userId, + hashIp2(ip), + now + ] + ); + return { id, event_type: eventType }; + }; + const pingBaidu = async (urls) => { + const list = Array.isArray(urls) ? urls.filter(Boolean) : [urls].filter(Boolean); + if (!baiduToken || list.length === 0) { + return { pushed: false, reason: "disabled_or_empty" }; + } + const endpoint = `http://data.zz.baidu.com/urls?site=${encodeURIComponent(baiduSite)}&token=${encodeURIComponent(baiduToken)}`; + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: list.join("\n") + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw Object.assign(new Error("\u767E\u5EA6\u63A8\u9001\u5931\u8D25"), { code: "baidu_push_failed", details: payload }); + } + return { pushed: true, result: payload }; + }; + const notifyPostPublished = async (postId) => { + const url = buildPostPublicUrl(postId, siteBase); + try { + return await pingBaidu([url]); + } catch (error) { + console.error("Plaza Baidu push failed:", error.message); + return { pushed: false, reason: error.message }; + } + }; + return { + listSitemapData, + recordAttribution, + pingBaidu, + notifyPostPublished + }; +} + +// plaza-ops.mjs +import crypto18 from "node:crypto"; +var REPORT_REASONS = /* @__PURE__ */ new Set(["spam", "violence", "porn", "political", "privacy", "other"]); +var FEATURED_POSITIONS = /* @__PURE__ */ new Set(["homepage_banner", "category_top", "trending"]); +var OPS_ROLE_RANK = { none: 0, reviewer: 1, editor: 2, ops_admin: 3 }; +function opsError(message, code, details) { + return Object.assign(new Error(message), { code, details }); +} +function clampLimit3(value, fallback = 20, max = 100) { + const limit = Number(value ?? fallback); + if (!Number.isFinite(limit) || limit < 1) return fallback; + return Math.min(Math.floor(limit), max); +} +function hasOpsRole(role, minimum) { + return (OPS_ROLE_RANK[role] ?? 0) >= (OPS_ROLE_RANK[minimum] ?? 0); +} +function createPlazaOpsService(pool, { + idFactory = () => crypto18.randomUUID(), + formatPostRow: formatPostRow2, + reviewPost, + invalidateFeedCaches = null +} = {}) { + const loadOperatorRole = async (userId) => { + const [rows] = await pool.query(`SELECT ops_role FROM h5_users WHERE id = ? LIMIT 1`, [userId]); + return rows[0]?.ops_role ?? "none"; + }; + const writeAuditLog = async (operatorId, action, targetType, targetId, detail) => { + await pool.query( + `INSERT INTO ops_audit_log (id, operator_id, action, target_type, target_id, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [idFactory(), operatorId, action, targetType, targetId, JSON.stringify(detail ?? {}), Date.now()] + ); + }; + const listReviewQueue = async ({ + status = "pending_review", + cursor = null, + limit = 20, + keyword = null + } = {}) => { + const pageLimit = clampLimit3(limit, 20, 100); + const params = [status]; + let cursorClause = ""; + if (cursor) { + cursorClause = "AND pp.published_at < (SELECT published_at FROM plaza_posts WHERE id = ? LIMIT 1)"; + params.push(cursor); + } + let keywordClause = ""; + if (keyword) { + keywordClause = "AND (pp.title LIKE ? OR pp.user_display_name LIKE ? OR pp.user_slug LIKE ?)"; + const pattern = `%${String(keyword).trim()}%`; + params.push(pattern, pattern, pattern); + } + params.push(pageLimit + 1); + const [rows] = await pool.query( + `SELECT pp.id, pp.title, pp.summary, pp.cover_url, pp.status, pp.user_display_name, pp.user_slug, + pp.published_at, pp.created_at, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon + FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id + WHERE pp.status = ? ${cursorClause} ${keywordClause} + ORDER BY pp.published_at DESC, pp.id DESC + LIMIT ?`, + params + ); + const hasMore = rows.length > pageLimit; + const pageRows = hasMore ? rows.slice(0, pageLimit) : rows; + const now = Date.now(); + const posts = pageRows.map((row) => ({ + id: row.id, + title: row.title, + summary: row.summary ?? "", + cover_url: row.cover_url ?? "", + status: row.status, + author: { display_name: row.user_display_name, slug: row.user_slug }, + category: { name: row.category_name, slug: row.category_slug, icon: row.category_icon ?? "" }, + published_at: new Date(Number(row.published_at)).toISOString(), + sla_warning: status === "pending_review" && now - Number(row.published_at) > 2 * 36e5 + })); + return { + posts, + next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null, + has_more: hasMore + }; + }; + const reviewPostAsOps = async (operatorId, postId, action, { reason = null } = {}) => { + const [beforeRows] = await pool.query(`SELECT status FROM plaza_posts WHERE id = ? LIMIT 1`, [postId]); + const before = beforeRows[0]; + if (!before) throw opsError("\u5E16\u5B50\u4E0D\u5B58\u5728", "POST_NOT_FOUND"); + const result = await reviewPost(postId, action, { reason }); + await writeAuditLog(operatorId, `${action}_post`, "post", postId, { + before_status: before.status, + after_status: result.status, + reason: reason ?? null + }); + return result; + }; + const batchReviewPosts = async (operatorId, { post_ids: postIds, action, reason = null } = {}) => { + const ids = Array.isArray(postIds) ? postIds.map((id) => String(id).trim()).filter(Boolean) : []; + if (ids.length === 0) throw opsError("post_ids \u4E0D\u80FD\u4E3A\u7A7A", "invalid_input"); + if (ids.length > 50) throw opsError("\u5355\u6B21\u6700\u591A\u5BA1\u6838 50 \u6761", "invalid_input"); + const posts = []; + for (const postId of ids) { + posts.push(await reviewPostAsOps(operatorId, postId, action, { reason })); + } + return { posts }; + }; + const createReport = async (reporterId, { target_type: targetType, target_id: targetId, reason, detail }) => { + const type = String(targetType ?? "").trim(); + if (type !== "post" && type !== "comment") { + throw opsError("\u4E0D\u652F\u6301\u7684\u4E3E\u62A5\u7C7B\u578B", "invalid_input"); + } + const reasonValue = String(reason ?? "").trim(); + if (!REPORT_REASONS.has(reasonValue)) throw opsError("\u4E0D\u652F\u6301\u7684\u4E3E\u62A5\u539F\u56E0", "invalid_input"); + if (type === "post") { + const [rows] = await pool.query( + `SELECT id FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`, + [targetId] + ); + if (!rows[0]) throw opsError("\u5E16\u5B50\u4E0D\u5B58\u5728", "POST_NOT_FOUND"); + } else { + const [rows] = await pool.query( + `SELECT id FROM plaza_comments WHERE id = ? AND status = 'visible' LIMIT 1`, + [targetId] + ); + if (!rows[0]) throw opsError("\u8BC4\u8BBA\u4E0D\u5B58\u5728", "COMMENT_NOT_FOUND"); + } + const id = idFactory(); + const now = Date.now(); + await pool.query( + `INSERT INTO plaza_reports + (id, target_type, target_id, reporter_id, reason, detail, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`, + [id, type, targetId, reporterId, reasonValue, String(detail ?? "").trim().slice(0, 500), now] + ); + return { id, status: "pending" }; + }; + const listReports = async ({ status = "pending", limit = 50 } = {}) => { + const pageLimit = clampLimit3(limit, 50, 100); + const [rows] = await pool.query( + `SELECT r.*, + (SELECT COUNT(*) FROM plaza_reports r2 + WHERE r2.target_type = r.target_type AND r2.target_id = r.target_id AND r2.status = 'pending') AS target_report_count + FROM plaza_reports r + WHERE r.status = ? + ORDER BY r.created_at DESC + LIMIT ?`, + [status, pageLimit] + ); + return { + reports: rows.map((row) => ({ + id: row.id, + target_type: row.target_type, + target_id: row.target_id, + reason: row.reason, + detail: row.detail, + status: row.status, + target_report_count: Number(row.target_report_count ?? 1), + created_at: new Date(Number(row.created_at)).toISOString() + })) + }; + }; + const processReport = async (operatorId, reportId, { action, action_taken: actionTaken = "" } = {}) => { + const [rows] = await pool.query(`SELECT * FROM plaza_reports WHERE id = ? LIMIT 1`, [reportId]); + const report = rows[0]; + if (!report) throw opsError("\u4E3E\u62A5\u4E0D\u5B58\u5728", "report_not_found"); + const now = Date.now(); + const status = action === "dismiss" ? "dismissed" : "processed"; + await pool.query( + `UPDATE plaza_reports + SET status = ?, processed_by = ?, processed_at = ?, action_taken = ? + WHERE id = ?`, + [status, operatorId, now, String(actionTaken).slice(0, 200), reportId] + ); + if (action === "hide_post" && report.target_type === "post") { + await pool.query( + `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ?`, + [now, report.target_id] + ); + await invalidateFeedCaches?.(); + } + await writeAuditLog(operatorId, "process_report", "report", reportId, { + action, + target_type: report.target_type, + target_id: report.target_id + }); + return { id: reportId, status }; + }; + const setFeatured = async (operatorId, { post_id: postId, position, sort_order: sortOrder = 0, expires_at: expiresAt = null }) => { + const pos = String(position ?? "").trim(); + if (!FEATURED_POSITIONS.has(pos)) throw opsError("\u4E0D\u652F\u6301\u7684\u7CBE\u9009\u4F4D", "invalid_input"); + const [postRows] = await pool.query( + `SELECT id FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`, + [postId] + ); + if (!postRows[0]) throw opsError("\u5E16\u5B50\u4E0D\u5B58\u5728\u6216\u672A\u53D1\u5E03", "POST_NOT_FOUND"); + const now = Date.now(); + const id = idFactory(); + const expiresMs = expiresAt ? new Date(expiresAt).getTime() : null; + await pool.query( + `INSERT INTO plaza_featured (id, post_id, position, sort_order, starts_at, expires_at, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [id, postId, pos, Number(sortOrder) || 0, now, expiresMs, operatorId, now] + ); + await invalidateFeedCaches?.(); + await writeAuditLog(operatorId, "set_featured", "featured", id, { post_id: postId, position: pos }); + return { id, post_id: postId, position: pos }; + }; + const listFeatured = async () => { + const now = Date.now(); + const [rows] = await pool.query( + `SELECT f.*, pp.title, pp.cover_url, pp.user_display_name + FROM plaza_featured f + JOIN plaza_posts pp ON pp.id = f.post_id + WHERE f.starts_at <= ? AND (f.expires_at IS NULL OR f.expires_at > ?) + ORDER BY f.position ASC, f.sort_order ASC, f.created_at DESC`, + [now, now] + ); + return { + items: rows.map((row) => ({ + id: row.id, + post_id: row.post_id, + position: row.position, + sort_order: row.sort_order, + title: row.title, + cover_url: row.cover_url, + author: row.user_display_name, + expires_at: row.expires_at ? new Date(Number(row.expires_at)).toISOString() : null + })) + }; + }; + const removeFeatured = async (operatorId, featuredId) => { + const [result] = await pool.query(`DELETE FROM plaza_featured WHERE id = ?`, [featuredId]); + if ((result.affectedRows ?? 0) === 0) throw opsError("\u7CBE\u9009\u4E0D\u5B58\u5728", "featured_not_found"); + await invalidateFeedCaches?.(); + await writeAuditLog(operatorId, "remove_featured", "featured", featuredId, {}); + return { id: featuredId }; + }; + const loadActiveFeaturedPosts = async (viewerId = null) => { + const now = Date.now(); + const [rows] = await pool.query( + `SELECT f.position, f.sort_order, pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon + FROM plaza_featured f + JOIN plaza_posts pp ON pp.id = f.post_id AND pp.status = 'published' + JOIN plaza_categories c ON c.id = pp.category_id + WHERE f.starts_at <= ? AND (f.expires_at IS NULL OR f.expires_at > ?) + ORDER BY f.position ASC, f.sort_order ASC`, + [now, now] + ); + const grouped = { homepage_banner: [], trending: [], category_top: {} }; + for (const row of rows) { + const post = formatPostRow2 ? formatPostRow2(row, { viewerReacted: null }) : row; + if (row.position === "homepage_banner") grouped.homepage_banner.push(post); + if (row.position === "trending") grouped.trending.push(post); + if (row.position === "category_top") { + const slug = row.category_slug; + if (!grouped.category_top[slug]) grouped.category_top[slug] = []; + grouped.category_top[slug].push(post); + } + } + return grouped; + }; + const getAnalyticsOverview = async () => { + const now = Date.now(); + const dayMs = 864e5; + const todayStart = now - now % dayMs; + const yesterdayStart = todayStart - dayMs; + const [[todayPosts]] = await pool.query( + `SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'published' AND published_at >= ?`, + [todayStart] + ); + const [[yesterdayPosts]] = await pool.query( + `SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'published' AND published_at >= ? AND published_at < ?`, + [yesterdayStart, todayStart] + ); + const [[pendingReview]] = await pool.query( + `SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'pending_review'` + ); + const [[signupFromPlaza]] = await pool.query( + `SELECT COUNT(*) AS count FROM plaza_attribution_events + WHERE event_type = 'signup' AND utm_source = 'plaza' AND created_at >= ?`, + [todayStart] + ); + const [categoryRows] = await pool.query( + `SELECT c.name, COUNT(p.id) AS count + FROM plaza_categories c + LEFT JOIN plaza_posts p ON p.category_id = c.id AND p.status = 'published' + GROUP BY c.id, c.name + ORDER BY count DESC` + ); + const [topCreators] = await pool.query( + `SELECT user_slug AS slug, user_display_name AS display_name, COUNT(*) AS post_count, + SUM(like_count) AS total_likes + FROM plaza_posts + WHERE status = 'published' + GROUP BY user_id, user_slug, user_display_name + ORDER BY total_likes DESC + LIMIT 10` + ); + const [dailyPosts] = await pool.query( + `SELECT DATE(FROM_UNIXTIME(published_at / 1000)) AS day, COUNT(*) AS count + FROM plaza_posts + WHERE status = 'published' AND published_at >= ? + GROUP BY day + ORDER BY day ASC`, + [now - 14 * dayMs] + ); + return { + today: { + new_posts: Number(todayPosts?.count ?? 0), + plaza_signups: Number(signupFromPlaza?.count ?? 0), + pending_review: Number(pendingReview?.count ?? 0) + }, + yesterday: { + new_posts: Number(yesterdayPosts?.count ?? 0) + }, + categories: categoryRows.map((row) => ({ + name: row.name, + count: Number(row.count ?? 0) + })), + top_creators: topCreators.map((row) => ({ + slug: row.slug, + display_name: row.display_name, + post_count: Number(row.post_count ?? 0), + total_likes: Number(row.total_likes ?? 0) + })), + daily_posts: dailyPosts.map((row) => ({ + day: row.day, + count: Number(row.count ?? 0) + })) + }; + }; + const listCreators = async ({ keyword = null, limit = 50 } = {}) => { + const pageLimit = clampLimit3(limit, 50, 100); + const params = []; + let keywordClause = ""; + if (keyword) { + keywordClause = "AND (u.username LIKE ? OR u.display_name LIKE ? OR u.slug LIKE ?)"; + const pattern = `%${String(keyword).trim()}%`; + params.push(pattern, pattern, pattern); + } + params.push(pageLimit); + const [rows] = await pool.query( + `SELECT u.id, u.slug, u.username, u.display_name, u.plaza_post_count, u.plaza_follower_count, + u.plaza_verified, u.plaza_post_banned, u.plaza_comment_banned, u.ops_role + FROM h5_users u + WHERE u.plaza_post_count > 0 ${keywordClause} + ORDER BY u.plaza_post_count DESC + LIMIT ?`, + params + ); + return { + creators: rows.map((row) => ({ + user_id: row.id, + slug: row.slug || row.username, + display_name: row.display_name || row.username, + post_count: Number(row.plaza_post_count ?? 0), + follower_count: Number(row.plaza_follower_count ?? 0), + verified: Boolean(row.plaza_verified), + post_banned: Boolean(row.plaza_post_banned), + comment_banned: Boolean(row.plaza_comment_banned) + })) + }; + }; + const updateCreator = async (operatorId, userId, patch) => { + const updates = []; + const params = []; + if (patch?.verified != null) { + updates.push("plaza_verified = ?"); + params.push(patch.verified ? 1 : 0); + } + if (patch?.post_banned != null) { + updates.push("plaza_post_banned = ?"); + params.push(patch.post_banned ? 1 : 0); + } + if (patch?.comment_banned != null) { + updates.push("plaza_comment_banned = ?"); + params.push(patch.comment_banned ? 1 : 0); + } + if (updates.length === 0) throw opsError("\u6CA1\u6709\u53EF\u66F4\u65B0\u7684\u5B57\u6BB5", "invalid_input"); + updates.push("updated_at = ?"); + params.push(Date.now(), userId); + const [result] = await pool.query( + `UPDATE h5_users SET ${updates.join(", ")} WHERE id = ?`, + params + ); + if ((result.affectedRows ?? 0) === 0) throw opsError("\u7528\u6237\u4E0D\u5B58\u5728", "user_not_found"); + await writeAuditLog(operatorId, "update_creator", "user", userId, patch); + return { user_id: userId }; + }; + return { + loadOperatorRole, + hasOpsRole, + listReviewQueue, + reviewPostAsOps, + batchReviewPosts, + createReport, + listReports, + processReport, + setFeatured, + listFeatured, + removeFeatured, + loadActiveFeaturedPosts, + getAnalyticsOverview, + listCreators, + updateCreator + }; +} + +// word-filter.mjs +import crypto19 from "node:crypto"; +function createWordFilterService(pool) { + async function listBlockedWords() { + const [rows] = await pool.query( + "SELECT id, word, replacement, note, status, created_at, updated_at FROM h5_blocked_words ORDER BY created_at DESC" + ); + return rows; + } + async function createBlockedWord({ word, replacement, note }) { + const trimmed = String(word ?? "").trim(); + if (!trimmed) return { ok: false, message: "\u8BCD\u8BED\u4E0D\u80FD\u4E3A\u7A7A" }; + const id = crypto19.randomUUID(); + const now = Date.now(); + try { + await pool.query( + "INSERT INTO h5_blocked_words (id, word, replacement, note, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + [id, trimmed, replacement ?? "***", note ?? "", "active", now, now] + ); + const [rows] = await pool.query("SELECT * FROM h5_blocked_words WHERE id = ?", [id]); + return { ok: true, blockedWord: rows[0] }; + } catch (err) { + if (err?.code === "ER_DUP_ENTRY") return { ok: false, message: "\u8BE5\u8BCD\u8BED\u5DF2\u5B58\u5728" }; + throw err; + } + } + async function updateBlockedWord(id, { word, replacement, note, status }) { + const updates = []; + const values = []; + if (word !== void 0) { + const trimmed = String(word).trim(); + if (!trimmed) return { ok: false, message: "\u8BCD\u8BED\u4E0D\u80FD\u4E3A\u7A7A" }; + updates.push("word = ?"); + values.push(trimmed); + } + if (replacement !== void 0) { + updates.push("replacement = ?"); + values.push(replacement); + } + if (note !== void 0) { + updates.push("note = ?"); + values.push(note); + } + if (status !== void 0) { + updates.push("status = ?"); + values.push(status); + } + if (!updates.length) return { ok: false, message: "\u6CA1\u6709\u53EF\u66F4\u65B0\u7684\u5B57\u6BB5" }; + updates.push("updated_at = ?"); + values.push(Date.now(), id); + const [result] = await pool.query( + `UPDATE h5_blocked_words SET ${updates.join(", ")} WHERE id = ?`, + values + ); + if (!result.affectedRows) return { ok: false, message: "\u8BB0\u5F55\u4E0D\u5B58\u5728" }; + const [rows] = await pool.query("SELECT * FROM h5_blocked_words WHERE id = ?", [id]); + return { ok: true, blockedWord: rows[0] }; + } + async function deleteBlockedWord(id) { + const [result] = await pool.query("DELETE FROM h5_blocked_words WHERE id = ?", [id]); + if (!result.affectedRows) return { ok: false, message: "\u8BB0\u5F55\u4E0D\u5B58\u5728" }; + return { ok: true }; + } + async function listAllForFrontend() { + const [rows] = await pool.query( + "SELECT word, replacement FROM h5_blocked_words WHERE status = ? ORDER BY word", + ["active"] + ); + return rows; + } + return { listBlockedWords, createBlockedWord, updateBlockedWord, deleteBlockedWord, listAllForFrontend }; +} + +// plaza-embed.mjs +function isPlazaEmbedRequest(query = {}) { + return String(query?.embed ?? "").toLowerCase() === "plaza"; +} +function publishedPageCspForEmbed(isFullHtml) { + if (isFullHtml) { + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'"; + } + return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'"; +} +function allowPlazaEmbedFrame(res) { + res.removeHeader?.("X-Frame-Options"); +} +var EMBED_BOOTSTRAP = ``; +var EMBED_LAYOUT_OVERRIDES = ''; +function stripPublicationHtmlCspMeta(html) { + return String(html ?? "").replace( + /]*>/gi, + "" + ); +} +function preparePublicationHtmlForEmbed(html) { + let source = stripPublicationHtmlCspMeta(html); + if (!source.includes('id="plaza-embed-overrides"') && /]*>/i.test(source)) { + source = source.replace(/]*)>/i, `${EMBED_LAYOUT_OVERRIDES}`); + } + return injectPlazaEmbedBootstrap(source); +} +function injectPlazaEmbedBootstrap(html) { + const source = String(html ?? ""); + if (source.includes('id="plaza-embed-bootstrap"')) return source; + if (/<\/body>/i.test(source)) { + return source.replace(/<\/body>/i, `${EMBED_BOOTSTRAP}`); + } + return `${source}${EMBED_BOOTSTRAP}`; +} + +// mindspace-cleanup.mjs +import crypto20 from "node:crypto"; +import fs16 from "node:fs/promises"; +import path17 from "node:path"; +var WORKSPACE_TEMP_SKIP = /* @__PURE__ */ new Set([ + ".tkmindhints", + ".goosehints", + ".tkmind-profile.json", + ".agents", + ".goose" +]); +function candidateId(kind, key) { + return `${kind}:${crypto20.createHash("sha256").update(key).digest("hex").slice(0, 24)}`; +} +async function fileSize(targetPath) { + try { + const stat = await fs16.stat(targetPath); + return stat.isFile() ? stat.size : 0; + } catch { + return 0; + } +} +async function walkFiles(rootDir, onFile) { + let entries; + try { + entries = await fs16.readdir(rootDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path17.join(rootDir, entry.name); + if (entry.isDirectory()) { + if (WORKSPACE_TEMP_SKIP.has(entry.name)) continue; + await walkFiles(fullPath, onFile); + continue; + } + if (!entry.isFile()) continue; + await onFile(fullPath); + } +} +function createCleanupService(pool, options = {}) { + const storageRoot = path17.resolve(options.storageRoot ?? path17.join(process.cwd(), "data", "mindspace")); + const h5Root = path17.resolve(options.h5Root ?? process.cwd()); + const absoluteStoragePath = (storageKey) => { + const resolved = path17.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path17.sep}`)) { + throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); + } + return resolved; + }; + const listCandidates = async (userId, username) => { + const candidates = []; + const [uploads] = await pool.query( + `SELECT id, filename, reserved_bytes, temporary_storage_key, status, expires_at, created_at + FROM h5_upload_sessions + WHERE user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed') + ORDER BY created_at DESC + LIMIT 100`, + [userId] + ); + for (const upload of uploads) { + const storagePath = upload.temporary_storage_key ? absoluteStoragePath(upload.temporary_storage_key) : null; + const exists = storagePath ? await fileSize(storagePath) : 0; + if (!exists && upload.status === "expired") continue; + candidates.push({ + id: candidateId("upload", upload.id), + kind: "stale_upload", + label: upload.filename, + path: upload.temporary_storage_key ?? "", + sizeBytes: exists || Number(upload.reserved_bytes ?? 0), + createdAt: Number(upload.created_at), + detail: upload.status === "reserved" ? "\u672A\u5B8C\u6210\u7684\u4E0A\u4F20\u9884\u7559" : upload.status === "uploaded" ? "\u5DF2\u4E0A\u4F20\u4F46\u672A\u5B8C\u6210\u5165\u5E93" : "\u5DF2\u5931\u6548\u7684\u4E0A\u4F20\u4E34\u65F6\u6587\u4EF6", + refId: upload.id + }); + } + const tmpDir = path17.join(storageRoot, "tmp", userId); + await walkFiles(tmpDir, async (fullPath) => { + const key = path17.relative(storageRoot, fullPath).split(path17.sep).join("/"); + const active = uploads.some( + (upload) => upload.temporary_storage_key === key && upload.status === "reserved" + ); + if (active) return; + const sizeBytes = await fileSize(fullPath); + if (!sizeBytes) return; + candidates.push({ + id: candidateId("tmp", key), + kind: "orphan_tmp", + label: path17.basename(fullPath), + path: key, + sizeBytes, + createdAt: null, + detail: "\u5B64\u7ACB\u7684\u4E34\u65F6\u4E0A\u4F20\u6587\u4EF6", + refId: key + }); + }); + const workspaceTempDir = path17.join(h5Root, "temp", username); + await walkFiles(workspaceTempDir, async (fullPath) => { + const rel = path17.relative(workspaceTempDir, fullPath).split(path17.sep).join("/"); + const sizeBytes = await fileSize(fullPath); + if (!sizeBytes) return; + candidates.push({ + id: candidateId("workspace", rel), + kind: "workspace_temp", + label: rel, + path: `temp/${username}/${rel}`, + sizeBytes, + createdAt: null, + detail: "Agent \u5DE5\u4F5C\u533A\u4E34\u65F6\u6587\u4EF6", + refId: rel + }); + }); + const [staleVersions] = await pool.query( + `SELECT pv.id AS version_id, pv.page_id, pv.version_no, pv.content_asset_id, pv.bundle_asset_id, + p.title, a.size_bytes, a.display_name, pv.created_at, + COALESCE(bundle.size_bytes, 0) AS bundle_size_bytes + FROM h5_page_versions pv + JOIN h5_page_records p ON p.id = pv.page_id AND p.user_id = ? AND p.status <> 'deleted' + JOIN h5_assets a ON a.id = pv.content_asset_id AND a.user_id = ? AND a.status <> 'deleted' + LEFT JOIN h5_assets bundle + ON bundle.id = pv.bundle_asset_id AND bundle.user_id = ? AND bundle.status <> 'deleted' + WHERE pv.id <> p.current_version_id + AND NOT EXISTS ( + SELECT 1 FROM h5_publish_records pr + WHERE pr.page_version_id = pv.id AND pr.user_id = ? + ) + ORDER BY pv.created_at ASC + LIMIT 500`, + [userId, userId, userId, userId] + ); + for (const row of staleVersions) { + const contentBytes = Number(row.size_bytes ?? 0); + const bundleBytes = Number(row.bundle_size_bytes ?? 0); + candidates.push({ + id: candidateId("page_version", row.version_id), + kind: "stale_page_version", + label: `${row.title} \xB7 v${row.version_no}`, + path: `page/${row.page_id}/version/${row.version_no}`, + sizeBytes: contentBytes + bundleBytes, + createdAt: Number(row.created_at), + detail: "\u9875\u9762\u5386\u53F2\u7248\u672C\uFF08\u975E\u5F53\u524D\u7248\u672C\uFF09", + refId: row.version_id + }); + } + return candidates; + }; + const runCleanup = async (userId, username, itemIds) => { + const selected = new Set(itemIds ?? []); + const candidates = await listCandidates(userId, username); + const targets = candidates.filter((item) => selected.has(item.id)); + let freedBytes = 0; + let removedCount = 0; + for (const item of targets) { + if (item.kind === "stale_upload") { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT id, space_id, reserved_bytes, temporary_storage_key, status + FROM h5_upload_sessions + WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed') + LIMIT 1 FOR UPDATE`, + [item.refId, userId] + ); + const upload = rows[0]; + if (upload) { + await conn.query( + `UPDATE h5_user_spaces + SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [upload.reserved_bytes, Date.now(), upload.space_id, userId] + ); + await conn.query( + `UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`, + [upload.id, userId] + ); + await conn.commit(); + if (upload.temporary_storage_key) { + const target = absoluteStoragePath(upload.temporary_storage_key); + const sizeBytes = await fileSize(target); + await fs16.rm(target, { force: true }); + freedBytes += sizeBytes; + } + removedCount += 1; + } else { + await conn.rollback(); + } + } catch { + await conn.rollback(); + } finally { + conn.release(); + } + continue; + } + if (item.kind === "orphan_tmp") { + const target = absoluteStoragePath(item.refId); + const sizeBytes = await fileSize(target); + await fs16.rm(target, { force: true }); + freedBytes += sizeBytes; + removedCount += 1; + continue; + } + if (item.kind === "workspace_temp") { + const target = path17.join(h5Root, "temp", username, item.refId); + const resolvedRoot = path17.resolve(path17.join(h5Root, "temp", username)); + const resolved = path17.resolve(target); + if (!resolved.startsWith(`${resolvedRoot}${path17.sep}`)) continue; + const sizeBytes = await fileSize(resolved); + await fs16.rm(resolved, { force: true }); + freedBytes += sizeBytes; + removedCount += 1; + continue; + } + if (item.kind === "stale_page_version") { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT pv.id, pv.page_id, pv.content_asset_id, pv.bundle_asset_id, + p.current_version_id, p.space_id + FROM h5_page_versions pv + JOIN h5_page_records p ON p.id = pv.page_id + WHERE pv.id = ? AND p.user_id = ? + LIMIT 1 FOR UPDATE`, + [item.refId, userId] + ); + const version = rows[0]; + if (!version || version.id === version.current_version_id) { + await conn.rollback(); + continue; + } + const [pubRows] = await conn.query( + `SELECT id FROM h5_publish_records WHERE page_version_id = ? AND user_id = ? LIMIT 1`, + [version.id, userId] + ); + if (pubRows.length) { + await conn.rollback(); + continue; + } + const now = Date.now(); + let freed = 0; + const assetsToDelete = [version.content_asset_id].filter(Boolean); + if (version.bundle_asset_id) { + const [bundleUse] = await conn.query( + `SELECT COUNT(*) AS cnt FROM h5_page_versions + WHERE bundle_asset_id = ? AND id <> ?`, + [version.bundle_asset_id, version.id] + ); + if (Number(bundleUse[0]?.cnt ?? 0) === 0) { + assetsToDelete.push(version.bundle_asset_id); + } + } + for (const assetId of assetsToDelete) { + const [assetRows] = await conn.query( + `SELECT id, size_bytes FROM h5_assets + WHERE id = ? AND user_id = ? AND status <> 'deleted' + LIMIT 1 FOR UPDATE`, + [assetId, userId] + ); + const asset = assetRows[0]; + if (!asset) continue; + await conn.query( + `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [now, now, assetId, userId] + ); + freed += Number(asset.size_bytes ?? 0); + } + await conn.query(`DELETE FROM h5_page_versions WHERE id = ?`, [version.id]); + if (freed > 0) { + await conn.query( + `UPDATE h5_user_spaces + SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [freed, now, version.space_id, userId] + ); + } + await conn.commit(); + freedBytes += freed; + removedCount += 1; + } catch { + await conn.rollback(); + } finally { + conn.release(); + } + } + } + return { removedCount, freedBytes }; + }; + return { listCandidates, runCleanup }; +} + +// mindspace-agent-jobs.mjs +import crypto21 from "node:crypto"; +import path18 from "node:path"; +var JOB_TYPES = /* @__PURE__ */ new Set(["generate_page", "analyze_asset", "summarize"]); +var OUTPUT_TYPES = /* @__PURE__ */ new Set(["page_draft", "html_page", "markdown"]); +var ACTIVE_JOB_STATUSES = /* @__PURE__ */ new Set(["queued", "running"]); +var RETRYABLE_ERROR_CODES = /* @__PURE__ */ new Set([ + "worker_crashed", + "worker_unavailable", + "model_rate_limited", + "network_temporary", + "storage_temporary", + "job_timed_out", + "invalid_agent_job_output" +]); +function asNumber5(value) { + return Number(value ?? 0); +} +function agentJobError(message, code, details) { + return Object.assign(new Error(message), { code, details }); +} +function dedupeStrings(values) { + return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value).trim()).filter(Boolean))]; +} +function normalizeInstruction(value) { + const instruction = String(value ?? "").normalize("NFKC").trim(); + if (!instruction) throw agentJobError("\u4EFB\u52A1\u8BF4\u660E\u4E0D\u80FD\u4E3A\u7A7A", "invalid_agent_job_input"); + if (instruction.length > 4e3) { + throw agentJobError("\u4EFB\u52A1\u8BF4\u660E\u8D85\u8FC7\u957F\u5EA6\u9650\u5236", "invalid_agent_job_input"); + } + return instruction; +} +function normalizeJobInput(input) { + const jobType = String(input.jobType ?? "").trim(); + if (!JOB_TYPES.has(jobType)) { + throw agentJobError("\u4E0D\u652F\u6301\u7684\u4EFB\u52A1\u7C7B\u578B", "invalid_agent_job_input"); + } + const outputType = String(input.outputType ?? "").trim(); + if (!OUTPUT_TYPES.has(outputType)) { + throw agentJobError("\u4E0D\u652F\u6301\u7684\u8F93\u51FA\u7C7B\u578B", "invalid_agent_job_input"); + } + const allowedAssetIds = dedupeStrings(input.allowedAssetIds); + if (allowedAssetIds.length === 0) { + throw agentJobError("\u81F3\u5C11\u9700\u8981\u6388\u6743\u4E00\u4E2A\u8F93\u5165\u8D44\u4EA7", "invalid_agent_job_input"); + } + return { + jobType, + instruction: normalizeInstruction(input.instruction), + outputType, + allowedAssetIds, + idempotencyKey: String(input.idempotencyKey ?? "").trim() || null, + outputCategoryId: input.outputCategoryId ? String(input.outputCategoryId) : null, + locale: String(input.locale ?? "zh-CN").trim() || "zh-CN", + timezone: String(input.timezone ?? "Asia/Shanghai").trim() || "Asia/Shanghai", + capabilities: { + network: Boolean(input.capabilities?.network), + shell: Boolean(input.capabilities?.shell), + createPage: input.capabilities?.createPage !== false + } + }; +} +function hashJobToken(token) { + return crypto21.createHash("sha256").update(String(token)).digest("hex"); +} +function jsonValue(value, fallback) { + if (value == null) return fallback; + if (typeof value === "object") return value; + try { + return JSON.parse(String(value)); + } catch { + return fallback; + } +} +function jobResponse(row, assets = []) { + if (!row) return null; + return { + id: row.id, + sessionId: row.session_id ?? null, + jobType: row.job_type, + instruction: row.instruction, + status: row.status, + outputType: row.output_type, + outputCategoryId: row.output_category_id, + outputCategoryCode: row.output_category_code ?? null, + progress: jsonValue(row.progress_json, { stage: row.status }), + permissionScope: jsonValue(row.permission_scope, {}), + userContext: jsonValue(row.user_context_json, { + locale: "zh-CN", + timezone: "Asia/Shanghai" + }), + resultPageId: row.result_page_id ?? null, + resultAssetId: row.result_asset_id ?? null, + errorCode: row.error_code ?? null, + errorMessage: row.error_message ?? null, + retryable: RETRYABLE_ERROR_CODES.has(row.error_code), + queuedAt: asNumber5(row.queued_at), + startedAt: row.started_at == null ? null : asNumber5(row.started_at), + completedAt: row.completed_at == null ? null : asNumber5(row.completed_at), + expiresAt: row.expires_at == null ? null : asNumber5(row.expires_at), + assets + }; +} +function outputContentFormat(outputType, input) { + if (input.contentFormat === "html" || outputType === "html_page") return "html"; + return "markdown"; +} +function progressPayload(input, fallbackStage) { + const stage = String(input?.stage ?? fallbackStage ?? "").trim() || fallbackStage || "running"; + const payload = { stage }; + const message = String(input?.message ?? "").trim(); + if (message) payload.message = message.slice(0, 500); + return payload; +} +function hasPathAccess(asset) { + return asset.status !== "deleted" && asset.status !== "quarantined" && asset.scan_status !== "blocked"; +} +function verifyTokenHash(expectedHash, token) { + if (!expectedHash || !token) return false; + const actualHash = hashJobToken(token); + const expected = Buffer.from(String(expectedHash), "hex"); + const actual = Buffer.from(actualHash, "hex"); + return expected.length === actual.length && crypto21.timingSafeEqual(expected, actual); +} +function createAgentJobService(pool, options = {}) { + const idFactory = options.idFactory ?? (() => crypto21.randomUUID()); + const nowFactory = options.nowFactory ?? (() => Date.now()); + const tokenTtlMs = Number(options.tokenTtlMs ?? 30 * 60 * 1e3); + const maxOutputBytes = Number(options.maxOutputBytes ?? 2 * 1024 * 1024); + const pageService = options.pageService; + const storageRoot = path18.resolve(options.storageRoot ?? path18.join(process.cwd(), "data", "mindspace")); + const absoluteStoragePath = (storageKey) => { + const resolved = path18.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path18.sep}`)) { + throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); + } + return resolved; + }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? "").replace(/\\/g, "/"); + const withoutMd = normalized.endsWith(".md") ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}` + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === "ENOENT") { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); + }; + const fetchJobAssets = async (jobId) => { + const [rows] = await pool.query( + `SELECT ja.asset_id, ja.asset_version_id, ja.permission, a.display_name, a.mime_type, + a.status, v.scan_status + FROM h5_agent_job_assets ja + JOIN h5_assets a ON a.id = ja.asset_id + JOIN h5_asset_versions v ON v.id = ja.asset_version_id + WHERE ja.job_id = ? + ORDER BY ja.created_at ASC`, + [jobId] + ); + return rows.map((row) => ({ + assetId: row.asset_id, + assetVersionId: row.asset_version_id, + permission: row.permission, + displayName: row.display_name, + mimeType: row.mime_type, + status: row.status, + scanStatus: row.scan_status + })); + }; + const fetchJobRow = async (jobId, userId = null) => { + const params = [jobId]; + let sql = `SELECT j.*, c.category_code AS output_category_code + FROM h5_agent_jobs j + JOIN h5_space_categories c ON c.id = j.output_category_id + WHERE j.id = ?`; + if (userId) { + sql += " AND j.user_id = ?"; + params.push(userId); + } + sql += " LIMIT 1"; + const [rows] = await pool.query(sql, params); + return rows[0] ?? null; + }; + const createJob = async (userId, input) => { + const normalized = normalizeJobInput(input); + if (!pageService) { + throw agentJobError("\u9875\u9762\u670D\u52A1\u672A\u521D\u59CB\u5316", "internal_error"); + } + if (normalized.idempotencyKey) { + const [existingRows] = await pool.query( + `SELECT j.*, c.category_code AS output_category_code + FROM h5_agent_jobs j + JOIN h5_space_categories c ON c.id = j.output_category_id + WHERE j.user_id = ? AND j.idempotency_key = ? + LIMIT 1`, + [userId, normalized.idempotencyKey] + ); + if (existingRows[0]) { + const assets = await fetchJobAssets(existingRows[0].id); + return jobResponse(existingRows[0], assets); + } + } + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const categoryParams = normalized.outputCategoryId ? [normalized.outputCategoryId, userId] : [userId]; + const [categoryRows] = await conn.query( + normalized.outputCategoryId ? `SELECT id, category_code + FROM h5_space_categories + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE` : `SELECT id, category_code + FROM h5_space_categories + WHERE user_id = ? AND category_code = 'draft' + LIMIT 1 + FOR UPDATE`, + categoryParams + ); + const category = categoryRows[0]; + if (!category) throw agentJobError("\u8F93\u51FA\u5206\u7C7B\u4E0D\u5B58\u5728", "category_not_found"); + if (!["draft", "private", "oa"].includes(category.category_code)) { + throw agentJobError("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u5199\u5165 Agent \u8F93\u51FA", "invalid_agent_job_input"); + } + const placeholders = normalized.allowedAssetIds.map(() => "?").join(", "); + const [assetRows] = await conn.query( + `SELECT a.id, a.current_version_id, a.display_name, a.mime_type, a.status, v.scan_status + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (${placeholders}) AND a.status <> 'deleted' + FOR UPDATE`, + [userId, ...normalized.allowedAssetIds] + ); + const assetMap = new Map(assetRows.map((row) => [row.id, row])); + const missingIds = normalized.allowedAssetIds.filter((assetId) => !assetMap.has(assetId)); + if (missingIds.length > 0) { + throw agentJobError("\u5B58\u5728\u672A\u6388\u6743\u6216\u4E0D\u5B58\u5728\u7684\u8D44\u4EA7", "asset_not_found", { + assetIds: missingIds + }); + } + const blockedAssets = assetRows.filter((row) => !hasPathAccess(row)); + if (blockedAssets.length > 0) { + throw agentJobError("\u6709\u8F93\u5165\u8D44\u4EA7\u5C1A\u672A\u901A\u8FC7\u5B89\u5168\u68C0\u67E5", "security_scan_required", { + assetIds: blockedAssets.map((row) => row.id) + }); + } + const jobId = idFactory(); + const now = nowFactory(); + await conn.query( + `INSERT INTO h5_agent_jobs + (id, user_id, job_type, instruction, permission_scope, user_context_json, + output_category_id, output_type, status, idempotency_key, progress_json, + queued_at, expires_at, updated_at, max_output_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)`, + [ + jobId, + userId, + normalized.jobType, + normalized.instruction, + JSON.stringify({ capabilities: normalized.capabilities }), + JSON.stringify({ locale: normalized.locale, timezone: normalized.timezone }), + category.id, + normalized.outputType, + normalized.idempotencyKey, + JSON.stringify({ stage: "queued" }), + now, + now + tokenTtlMs, + now, + maxOutputBytes + ] + ); + for (const assetId of normalized.allowedAssetIds) { + const asset = assetMap.get(assetId); + await conn.query( + `INSERT INTO h5_agent_job_assets + (id, job_id, asset_id, asset_version_id, permission, created_at) + VALUES (?, ?, ?, ?, 'read', ?)`, + [idFactory(), jobId, asset.id, asset.current_version_id, now] + ); + } + await conn.commit(); + const created = await fetchJobRow(jobId, userId); + const assets = await fetchJobAssets(jobId); + return jobResponse(created, assets); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const getJob = async (userId, jobId) => { + const row = await fetchJobRow(jobId, userId); + if (!row) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found"); + const assets = await fetchJobAssets(jobId); + return jobResponse(row, assets); + }; + const listJobs = async (userId, { limit = 20, offset = 0 } = {}) => { + const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + const safeOffset = Math.max(Number(offset) || 0, 0); + const [countRows] = await pool.query( + `SELECT COUNT(*) AS total FROM h5_agent_jobs WHERE user_id = ?`, + [userId] + ); + const total = Number(countRows[0]?.total ?? 0); + const [rows] = await pool.query( + `SELECT j.*, c.category_code AS output_category_code + FROM h5_agent_jobs j + JOIN h5_space_categories c ON c.id = j.output_category_id + WHERE j.user_id = ? + ORDER BY COALESCE(j.completed_at, j.started_at, j.queued_at) DESC + LIMIT ${safeLimit} OFFSET ${safeOffset}`, + [userId] + ); + const jobs = await Promise.all( + rows.map(async (row) => jobResponse(row, await fetchJobAssets(row.id))) + ); + return { + items: jobs, + total, + offset: safeOffset, + limit: safeLimit, + hasMore: safeOffset + jobs.length < total + }; + }; + const cancelJob = async (userId, jobId) => { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT * FROM h5_agent_jobs + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE`, + [jobId, userId] + ); + const job = rows[0]; + if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found"); + if (!ACTIVE_JOB_STATUSES.has(job.status)) { + throw agentJobError("\u5F53\u524D\u72B6\u6001\u4E0D\u5141\u8BB8\u53D6\u6D88\u4EFB\u52A1", "invalid_state_transition"); + } + const now = nowFactory(); + await conn.query( + `UPDATE h5_agent_jobs + SET status = 'cancelled', completed_at = ?, expires_at = ?, updated_at = ?, + job_token_hash = NULL, heartbeat_at = NULL, progress_json = ? + WHERE id = ?`, + [now, now, now, JSON.stringify({ stage: "cancelled" }), jobId] + ); + await conn.commit(); + return getJob(userId, jobId); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const retryJob = async (userId, jobId) => { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT * FROM h5_agent_jobs + WHERE id = ? AND user_id = ? + LIMIT 1 + FOR UPDATE`, + [jobId, userId] + ); + const job = rows[0]; + if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found"); + if (!["failed", "timed_out", "cancelled"].includes(job.status)) { + throw agentJobError("\u5F53\u524D\u72B6\u6001\u4E0D\u5141\u8BB8\u91CD\u8BD5\u4EFB\u52A1", "invalid_state_transition"); + } + if (job.status === "failed" && !RETRYABLE_ERROR_CODES.has(job.error_code)) { + throw agentJobError("\u8BE5\u4EFB\u52A1\u9519\u8BEF\u4E0D\u53EF\u81EA\u52A8\u91CD\u8BD5", "invalid_state_transition"); + } + const now = nowFactory(); + await conn.query( + `UPDATE h5_agent_jobs + SET status = 'queued', progress_json = ?, started_at = NULL, completed_at = NULL, + error_code = NULL, error_message = NULL, result_page_id = NULL, result_asset_id = NULL, + job_token_hash = NULL, heartbeat_at = NULL, expires_at = ?, updated_at = ? + WHERE id = ?`, + [JSON.stringify({ stage: "queued" }), now + tokenTtlMs, now, jobId] + ); + await conn.commit(); + return getJob(userId, jobId); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const claimJob = async (jobId) => { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT j.*, c.category_code AS output_category_code + FROM h5_agent_jobs j + JOIN h5_space_categories c ON c.id = j.output_category_id + WHERE j.id = ? + LIMIT 1 + FOR UPDATE`, + [jobId] + ); + const job = rows[0]; + if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found"); + if (job.status !== "queued") { + throw agentJobError("\u4EFB\u52A1\u5F53\u524D\u4E0D\u53EF\u9886\u53D6", "invalid_state_transition"); + } + if (job.expires_at != null && asNumber5(job.expires_at) < nowFactory()) { + throw agentJobError("\u4EFB\u52A1\u5DF2\u8FC7\u671F", "agent_job_expired"); + } + const token = crypto21.randomBytes(24).toString("base64url"); + const now = nowFactory(); + await conn.query( + `UPDATE h5_agent_jobs + SET status = 'running', started_at = COALESCE(started_at, ?), heartbeat_at = ?, + updated_at = ?, expires_at = ?, progress_json = ?, job_token_hash = ? + WHERE id = ?`, + [ + now, + now, + now, + now + tokenTtlMs, + JSON.stringify({ stage: "preparing_files" }), + hashJobToken(token), + jobId + ] + ); + await conn.commit(); + const assets = await fetchJobAssets(jobId); + const permissionScope = jsonValue(job.permission_scope, {}); + const userContext = jsonValue(job.user_context_json, { + locale: "zh-CN", + timezone: "Asia/Shanghai" + }); + return { + jobId, + userId: job.user_id, + jobToken: token, + instruction: job.instruction, + userContext, + allowedAssets: assets.map((asset) => ({ + assetId: asset.assetId, + versionId: asset.assetVersionId, + permission: asset.permission, + displayName: asset.displayName, + mimeType: asset.mimeType, + downloadEndpoint: `/api/internal/agent/jobs/${jobId}/assets/${asset.assetId}` + })), + output: { + categoryId: job.output_category_id, + categoryCode: job.output_category_code, + allowedTypes: [job.output_type], + maxBytes: asNumber5(job.max_output_bytes) + }, + capabilities: permissionScope.capabilities ?? { + network: false, + shell: false, + createPage: true + }, + expiresAt: now + tokenTtlMs + }; + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; + const requireJobToken = async (jobId, token) => { + const [rows] = await pool.query( + `SELECT j.*, c.category_code AS output_category_code + FROM h5_agent_jobs j + JOIN h5_space_categories c ON c.id = j.output_category_id + WHERE j.id = ? + LIMIT 1`, + [jobId] + ); + const job = rows[0]; + if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found"); + if (job.status !== "running") { + throw agentJobError("\u4EFB\u52A1\u672A\u5904\u4E8E\u6267\u884C\u4E2D\u72B6\u6001", "invalid_state_transition"); + } + if (job.expires_at != null && asNumber5(job.expires_at) < nowFactory()) { + throw agentJobError("\u4EFB\u52A1 token \u5DF2\u8FC7\u671F", "agent_job_expired"); + } + if (!verifyTokenHash(job.job_token_hash, token)) { + throw agentJobError("\u4EFB\u52A1 token \u65E0\u6548", "agent_job_token_invalid"); + } + return job; + }; + const getAssetForJob = async (jobId, token, assetId) => { + await requireJobToken(jobId, token); + const [rows] = await pool.query( + `SELECT a.id, a.display_name, a.mime_type, a.status, v.id AS asset_version_id, + v.storage_key, v.scan_status + FROM h5_agent_job_assets ja + JOIN h5_assets a ON a.id = ja.asset_id + JOIN h5_asset_versions v ON v.id = ja.asset_version_id + WHERE ja.job_id = ? AND ja.asset_id = ? + LIMIT 1`, + [jobId, assetId] + ); + const asset = rows[0]; + if (!asset) throw agentJobError("\u4EFB\u52A1\u8D44\u4EA7\u4E0D\u5B58\u5728", "asset_not_found"); + if (!hasPathAccess(asset)) { + throw agentJobError("\u4EFB\u52A1\u8D44\u4EA7\u5C1A\u672A\u901A\u8FC7\u5B89\u5168\u68C0\u67E5", "security_scan_required"); + } + return { + assetId: asset.id, + assetVersionId: asset.asset_version_id, + displayName: asset.display_name, + mimeType: asset.mime_type, + path: await resolveReadableStoragePath(asset.storage_key) + }; + }; + const heartbeat = async (jobId, token, input) => { + const job = await requireJobToken(jobId, token); + const now = nowFactory(); + const payload = progressPayload(input, "running"); + await pool.query( + `UPDATE h5_agent_jobs + SET heartbeat_at = ?, updated_at = ?, expires_at = ?, progress_json = ? + WHERE id = ?`, + [now, now, now + tokenTtlMs, JSON.stringify(payload), jobId] + ); + return jobResponse({ ...job, progress_json: JSON.stringify(payload), heartbeat_at: now }); + }; + const completeJob = async (jobId, token, input) => { + const job = await requireJobToken(jobId, token); + const now = nowFactory(); + if (input?.status === "failed") { + const errorCode = String(input.errorCode ?? "worker_crashed").trim() || "worker_crashed"; + const errorMessage = String(input.errorMessage ?? "\u4EFB\u52A1\u6267\u884C\u5931\u8D25").trim() || "\u4EFB\u52A1\u6267\u884C\u5931\u8D25"; + await pool.query( + `UPDATE h5_agent_jobs + SET status = 'failed', error_code = ?, error_message = ?, completed_at = ?, + updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL, progress_json = ? + WHERE id = ?`, + [errorCode, errorMessage.slice(0, 1e3), now, now, JSON.stringify({ stage: "failed" }), jobId] + ); + return getJob(job.user_id, jobId); + } + const content = String(input?.content ?? ""); + const outputBytes = Buffer.byteLength(content, "utf8"); + if (!content.trim()) { + throw agentJobError("\u4EFB\u52A1\u8F93\u51FA\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A", "invalid_agent_job_output"); + } + if (outputBytes > asNumber5(job.max_output_bytes)) { + throw agentJobError("\u4EFB\u52A1\u8F93\u51FA\u8D85\u8FC7\u5927\u5C0F\u9650\u5236", "page_content_too_large", { + maxBytes: asNumber5(job.max_output_bytes) + }); + } + const sourceAssetIds = dedupeStrings(input?.sourceAssetIds); + const boundAssets = await fetchJobAssets(jobId); + const boundAssetIdSet = new Set(boundAssets.map((asset) => asset.assetId)); + const invalidSourceIds = sourceAssetIds.filter((assetId) => !boundAssetIdSet.has(assetId)); + if (invalidSourceIds.length > 0) { + throw agentJobError("\u8F93\u51FA\u5F15\u7528\u4E86\u672A\u6388\u6743\u7684\u8F93\u5165\u8D44\u4EA7", "invalid_agent_job_output", { + assetIds: invalidSourceIds + }); + } + const page = await pageService.createFromAgent( + job.user_id, + { + title: input?.title, + summary: input?.summary, + content, + contentFormat: outputContentFormat(job.output_type, input), + pageType: input?.pageType, + templateId: input?.templateId, + categoryCode: job.output_category_code, + changeNote: `Agent job ${jobId}` + }, + { + jobId, + assetId: sourceAssetIds[0] ?? null, + assetIds: sourceAssetIds + } + ); + await pool.query( + `UPDATE h5_agent_jobs + SET status = 'completed', result_page_id = ?, completed_at = ?, updated_at = ?, + heartbeat_at = NULL, job_token_hash = NULL, progress_json = ? + WHERE id = ?`, + [page.id, now, now, JSON.stringify({ stage: "completed" }), jobId] + ); + return getJob(job.user_id, jobId); + }; + return { + createJob, + getJob, + listJobs, + cancelJob, + retryJob, + claimJob, + getAssetForJob, + heartbeat, + completeJob + }; +} + +// mindspace-agent-runner.mjs +import crypto22 from "node:crypto"; +import fs17 from "node:fs/promises"; +import { Readable as Readable2 } from "node:stream"; +import { Agent as Agent5, fetch as undiciFetch5 } from "undici"; +import { jsonrepair as jsonrepair2 } from "jsonrepair"; + +// message-stream.mjs +function mergeStreamingChunk(previous, incoming) { + const prev = String(previous ?? ""); + const next = String(incoming ?? ""); + if (!next) return prev; + if (!prev) return next; + if (next === prev) return prev; + if (next.startsWith(prev)) return next; + if (prev.startsWith(next)) return prev; + if (prev.endsWith(next)) return prev; + const maxOverlap = Math.min(prev.length, next.length); + for (let size = maxOverlap; size > 0; size -= 1) { + if (prev.endsWith(next.slice(0, size))) { + return prev + next.slice(size); + } + } + return prev + next; +} +function findLastContentIndex(content, type) { + for (let index = content.length - 1; index >= 0; index -= 1) { + if (content[index]?.type === type) return index; + } + return -1; +} +function mergeTextLikeBlock(block, incomingValue, field) { + block[field] = mergeStreamingChunk(block[field], incomingValue); +} +function mergeMessageContent(existingContent, incomingContent) { + const merged = [...existingContent]; + for (const item of incomingContent) { + if (item.type === "text") { + const index = findLastContentIndex(merged, "text"); + if (index >= 0) { + mergeTextLikeBlock(merged[index], item.text, "text"); + continue; + } + merged.push({ ...item }); + continue; + } + if (item.type === "thinking") { + const index = findLastContentIndex(merged, "thinking"); + if (index >= 0) { + mergeTextLikeBlock(merged[index], item.thinking, "thinking"); + continue; + } + merged.push({ ...item }); + continue; + } + const tail = merged[merged.length - 1]; + if (tail && JSON.stringify(tail) === JSON.stringify(item)) continue; + merged.push({ ...item }); + } + return merged; +} + +// mindspace-agent-runner.mjs +var insecureDispatcher5 = new Agent5({ + connect: { rejectUnauthorized: false } +}); +var DEFAULT_TEXT_BYTES = 48 * 1024; +function isHttpsTarget3(target) { + return String(target).startsWith("https://"); +} +function runnerError(message, code, details) { + return Object.assign(new Error(message), { code, details }); +} +function createUserMessage(text) { + return { + id: crypto22.randomUUID(), + role: "user", + created: Math.floor(Date.now() / 1e3), + content: [{ type: "text", text }], + metadata: { userVisible: true, agentVisible: true } + }; +} +function messageVisibleText(message) { + return (message?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join(""); +} +function pushMessage(messages, incoming) { + const last = messages[messages.length - 1]; + if (last?.id && incoming?.id && last.id === incoming.id) { + return [ + ...messages.slice(0, -1), + { + ...last, + content: mergeMessageContent(last.content, incoming.content) + } + ]; + } + return [...messages, incoming]; +} +function extractBalancedJsonObject(text, startIndex = 0) { + let depth = 0; + let inString = false; + let escape = false; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (inString) { + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === "{") depth += 1; + else if (ch === "}") { + depth -= 1; + if (depth === 0) return text.slice(startIndex, i + 1); + } + } + return null; +} +function collectJsonCandidates(text) { + const source = String(text ?? "").trim(); + const candidates = []; + const seen = /* @__PURE__ */ new Set(); + const push = (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed || seen.has(trimmed)) return; + seen.add(trimmed); + candidates.push(trimmed); + }; + for (const match of source.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) { + push(match[1]); + } + push(source); + for (const candidate of [...candidates]) { + for (let i = 0; i < candidate.length; i += 1) { + if (candidate[i] !== "{") continue; + const balanced = extractBalancedJsonObject(candidate, i); + if (balanced) push(balanced); + } + } + return candidates; +} +function parseJsonObject2(text) { + for (const candidate of collectJsonCandidates(text)) { + for (const normalized of [candidate, jsonrepair2(candidate)]) { + try { + const parsed = JSON.parse(normalized); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + } + } + } + return null; +} +function extractJsonObject2(text) { + const source = String(text ?? "").trim(); + const parsed = parseJsonObject2(source); + if (parsed) return parsed; + const start = source.indexOf("{"); + const end = source.lastIndexOf("}"); + if (start < 0 || end <= start) { + throw runnerError("Agent \u8F93\u51FA\u7F3A\u5C11 JSON \u7ED3\u679C", "invalid_agent_job_output"); + } + const raw = extractBalancedJsonObject(source, start) ?? source.slice(start, end + 1); + try { + return JSON.parse(jsonrepair2(raw)); + } catch (error) { + throw runnerError("Agent \u8F93\u51FA JSON \u89E3\u6790\u5931\u8D25", "invalid_agent_job_output", { + raw: raw.slice(0, 2e3), + cause: error instanceof Error ? error.message : String(error) + }); + } +} +function normalizeStructuredResult(payload) { + const title = String(payload?.title ?? "").trim(); + const summary = String(payload?.summary ?? "").trim(); + const content = String(payload?.content ?? payload?.markdown ?? payload?.body ?? "").trim(); + const contentFormat = String(payload?.content_format ?? payload?.contentFormat ?? "markdown").trim().toLowerCase(); + if (!title || !content) { + throw runnerError("Agent \u8F93\u51FA\u7F3A\u5C11\u6807\u9898\u6216\u6B63\u6587", "invalid_agent_job_output"); + } + return { + title, + summary, + content, + contentFormat: contentFormat === "html" ? "html" : "markdown", + pageType: contentFormat === "html" ? "html" : "article", + templateId: contentFormat === "html" ? "static-html" : "report" + }; +} +async function readAssetContext(asset, maxBytes = DEFAULT_TEXT_BYTES) { + const mimeType = String(asset.mimeType ?? ""); + const textLike = mimeType.startsWith("text/") || mimeType === "application/json" || mimeType.includes("xml") || mimeType.includes("javascript"); + if (!textLike) { + return { + assetId: asset.assetId, + displayName: asset.displayName, + mimeType, + excerpt: "", + note: "\u8BE5\u6587\u4EF6\u4E0D\u662F\u7EAF\u6587\u672C\uFF0CRunner \u5F53\u524D\u4E0D\u4F1A\u76F4\u63A5\u5185\u5D4C\u4E8C\u8FDB\u5236\u5185\u5BB9\u3002" + }; + } + const buffer = await fs17.readFile(asset.path); + return { + assetId: asset.assetId, + displayName: asset.displayName, + mimeType, + excerpt: buffer.subarray(0, maxBytes).toString("utf8"), + truncated: buffer.length > maxBytes, + note: buffer.length > maxBytes ? `\u5185\u5BB9\u5DF2\u622A\u65AD\u5230 ${maxBytes} \u5B57\u8282\u3002` : void 0 + }; +} +function buildAgentJobPrompt(job, assetContexts) { + const assetSections = assetContexts.map((asset, index) => { + const header = `\u8D44\u6599 ${index + 1}: ${asset.displayName} (${asset.mimeType || "unknown"})`; + const note = asset.note ? `\u8BF4\u660E: ${asset.note} +` : ""; + const body = asset.excerpt ? `\u5185\u5BB9: +<<>> +${asset.excerpt} +<<>>` : "\u5185\u5BB9: [\u672A\u5185\u5D4C\u6587\u672C\u5185\u5BB9]"; + return `${header} +${note}${body}`; + }).join("\n\n"); + return [ + "\u4F60\u6B63\u5728\u4E3A MindSpace \u751F\u6210\u4E00\u4E2A\u9875\u9762\u8349\u7A3F\u3002", + "\u4F60\u53EA\u80FD\u57FA\u4E8E\u7ED9\u5B9A\u8D44\u6599\u548C\u7528\u6237\u4EFB\u52A1\u751F\u6210\u7ED3\u679C\uFF0C\u4E0D\u80FD\u5047\u8BBE\u989D\u5916\u4E8B\u5B9E\u3002", + "\u4E0D\u8981\u8BF7\u6C42\u5DE5\u5177\u786E\u8BA4\uFF0C\u4E0D\u8981\u8F93\u51FA\u89E3\u91CA\uFF0C\u4E0D\u8981\u8C03\u7528\u5DE5\u5177\u3002", + "\u6700\u7EC8\u56DE\u590D\u5FC5\u987B\u662F\u4E00\u4E2A\u5408\u6CD5 JSON \u5BF9\u8C61\uFF0C\u4E14\u81F3\u5C11\u5305\u542B title\u3001summary\u3001content \u4E09\u4E2A\u5B57\u6BB5\u3002", + 'content \u53EF\u4EE5\u662F Markdown \u6216 HTML\uFF1B\u5982\u9700 HTML\uFF0C\u8BF7\u628A content_format \u8BBE\u4E3A "html"\u3002', + "\u793A\u4F8B\uFF1A", + '{"title":"\u9875\u9762\u6807\u9898","summary":"\u4E00\u53E5\u8BDD\u6458\u8981","content":"# \u6807\u9898\\n\\n\u6B63\u6587\u6BB5\u843D","content_format":"markdown"}', + `\u7528\u6237\u4EFB\u52A1: ${job.instruction}`, + "", + assetSections + ].join("\n"); +} +async function readJsonResponse(response) { + const text = await response.text(); + if (!response.ok) { + throw new Error(text || `upstream ${response.status}`); + } + return text ? JSON.parse(text) : null; +} +async function defaultExecuteSessionReply(apiFetch2, sessionId, requestId, prompt) { + const eventsResponse = await apiFetch2(`/sessions/${sessionId}/events`, { + method: "GET", + headers: { Accept: "text/event-stream" } + }); + if (!eventsResponse.ok || !eventsResponse.body) { + const text = await eventsResponse.text().catch(() => ""); + throw runnerError(text || "\u65E0\u6CD5\u5EFA\u7ACB\u4EFB\u52A1\u4E8B\u4EF6\u6D41", "worker_unavailable"); + } + const replyResponse = await apiFetch2(`/sessions/${sessionId}/reply`, { + method: "POST", + body: JSON.stringify({ + request_id: requestId, + user_message: createUserMessage(prompt) + }) + }); + if (!replyResponse.ok) { + const text = await replyResponse.text().catch(() => ""); + throw runnerError(text || "Agent reply \u8BF7\u6C42\u5931\u8D25", "worker_unavailable"); + } + replyResponse.body?.cancel().catch?.(() => { + }); + const reader = Readable2.fromWeb(eventsResponse.body); + const decoder = new TextDecoder(); + let buffer = ""; + let messages = []; + for await (const chunk of reader) { + buffer += decoder.decode(chunk, { stream: true }); + const frames = buffer.split("\n\n"); + buffer = frames.pop() ?? ""; + for (const frame of frames) { + let data = ""; + for (const line of frame.split("\n")) { + if (line.startsWith("data:")) data += `${line.slice(5).trim()}`; + } + if (!data) continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + const routingId = event.chat_request_id ?? event.request_id; + if (routingId && routingId !== requestId) continue; + if (event.type === "Message" && event.message?.metadata?.userVisible) { + const hasActionRequired = event.message.content?.some((item) => item.type === "actionRequired"); + if (hasActionRequired) { + throw runnerError("\u4EFB\u52A1\u6267\u884C\u9700\u8981\u4EBA\u5DE5\u786E\u8BA4\uFF0CRunner \u5F53\u524D\u65E0\u6CD5\u81EA\u52A8\u5904\u7406", "worker_unavailable"); + } + messages = pushMessage(messages, event.message); + } else if (event.type === "UpdateConversation") { + messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + } else if (event.type === "Error") { + throw runnerError(event.error || "\u4EFB\u52A1\u6267\u884C\u5931\u8D25", "worker_unavailable"); + } else if (event.type === "Finish") { + const assistant = [...messages].reverse().find((item) => item.role === "assistant"); + return { + text: messageVisibleText(assistant), + tokenState: event.token_state ?? null + }; + } + } + } + throw runnerError("\u4EFB\u52A1\u4E8B\u4EF6\u6D41\u63D0\u524D\u7ED3\u675F", "worker_unavailable"); +} +function createMindSpaceAgentRunner({ + apiTarget, + apiSecret, + userAuth: userAuth2, + agentJobService, + executeSessionReply: executeSessionReply2 = defaultExecuteSessionReply, + apiFetchImpl = null +}) { + const apiFetch2 = apiFetchImpl ?? (async (pathname, init = {}) => { + const url = new URL(pathname, apiTarget); + const headers = { + ...init.headers ?? {}, + "X-Secret-Key": apiSecret + }; + if (init.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + return undiciFetch5(url, { + ...init, + headers, + dispatcher: isHttpsTarget3(apiTarget) ? insecureDispatcher5 : void 0 + }); + }); + const runJob = async (jobId) => { + let claim = null; + let sessionId = null; + try { + claim = await agentJobService.claimJob(jobId); + const gate = await userAuth2.canUseChat(claim.userId); + if (!gate.ok) { + throw runnerError(gate.message || "\u5F53\u524D\u7528\u6237\u65E0\u6CD5\u6267\u884C Agent \u4EFB\u52A1", "worker_unavailable"); + } + const workingDir = await userAuth2.resolveWorkingDir(claim.userId); + const sessionPolicy = await userAuth2.getAgentSessionPolicy(claim.userId); + const publishLayout = await userAuth2.getUserPublishLayout(claim.userId); + const startSession = await readJsonResponse( + await apiFetch2("/agent/start", { + method: "POST", + body: JSON.stringify({ + working_dir: workingDir, + enable_context_memory: sessionPolicy.enableContextMemory, + ...sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {} + }) + }) + ); + sessionId = startSession?.id; + if (!sessionId) { + throw runnerError("Agent \u4F1A\u8BDD\u542F\u52A8\u5931\u8D25", "worker_unavailable"); + } + await userAuth2.registerAgentSession(claim.userId, sessionId); + await reconcileAgentSession( + (pathname, init) => apiFetch2(pathname, init), + sessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout ? { + userId: claim.userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + } + ); + const assetContexts = []; + for (const asset of claim.allowedAssets) { + const localAsset = await agentJobService.getAssetForJob(jobId, claim.jobToken, asset.assetId); + assetContexts.push(await readAssetContext(localAsset)); + } + const prompt = buildAgentJobPrompt(claim, assetContexts); + const requestId = crypto22.randomUUID(); + const reply = await executeSessionReply2(apiFetch2, sessionId, requestId, prompt); + const parsed = normalizeStructuredResult(extractJsonObject2(reply.text)); + if (reply.tokenState) { + await userAuth2.billSessionUsage(claim.userId, sessionId, reply.tokenState, requestId); + } + return agentJobService.completeJob(jobId, claim.jobToken, { + title: parsed.title, + summary: parsed.summary, + content: parsed.content, + contentFormat: parsed.contentFormat, + pageType: parsed.pageType, + templateId: parsed.templateId, + sourceAssetIds: claim.allowedAssets.map((asset) => asset.assetId) + }); + } catch (error) { + if (claim?.jobToken) { + await agentJobService.completeJob(jobId, claim.jobToken, { + status: "failed", + errorCode: error?.code ?? "worker_unavailable", + errorMessage: error instanceof Error ? error.message : String(error) + }).catch(() => { + }); + } + throw error; + } + }; + return { runJob }; +} + +// mindspace-chat-save.mjs +import fs18 from "node:fs/promises"; +import path19 from "node:path"; +var URL_PATTERN = /https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; +function decodePathSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} +function encodeUrlPath(relativePath) { + return String(relativePath ?? "").split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/"); +} +function normalizeStaticHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); + if (parts.length === 0) return ""; + if (parts[0].toLowerCase() === "public") return ["public", ...parts.slice(1)].join("/"); + if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; + return parts.join("/"); +} +function canonicalizeStaticPageUrl(publicUrl2, originalRelativePath, canonicalRelativePath) { + if (!canonicalRelativePath || canonicalRelativePath === String(originalRelativePath ?? "").replace(/^\/+/, "")) { + return publicUrl2; + } + const suffix = encodeUrlPath(originalRelativePath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return String(publicUrl2).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); +} +function extractStaticPageLinks(content, { userId, username } = {}) { + const text = String(content ?? ""); + const links = []; + const seen = /* @__PURE__ */ new Set(); + const normalizedUserId = userId ? String(userId).trim().toLowerCase() : null; + const normalizedUsername = username ? String(username).trim().toLowerCase() : null; + for (const match of text.matchAll(URL_PATTERN)) { + const owner = decodePathSegment(match[1]).toLowerCase(); + const originalRelativePath = decodePathSegment(match[2]); + const relativePath = normalizeStaticHtmlRelativePath(originalRelativePath); + if (normalizedUserId) { + if (owner !== normalizedUserId && owner !== normalizedUsername) continue; + } else if (normalizedUsername && owner !== normalizedUsername) { + continue; + } + const key = `${owner}/${relativePath}`; + if (seen.has(key)) continue; + seen.add(key); + links.push({ + publicUrl: canonicalizeStaticPageUrl(match[0], originalRelativePath, relativePath), + owner, + relativePath, + filename: path19.basename(relativePath) + }); + } + return links; +} +function buildWorkspaceAssetUrl(userId, relativePath) { + const key = String(userId ?? "").trim(); + const clean = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== "..").map((part) => encodeURIComponent(part)).join("/"); + if (!key || !clean) return null; + return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${clean}`; +} +function buildWorkspaceThumbnailUrl(userId, htmlRelativePath) { + const thumbRel = workspaceThumbnailRelativePath(htmlRelativePath); + return buildWorkspaceAssetUrl(userId, thumbRel); +} +function resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath) { + const key = String(userId ?? "").trim().toLowerCase(); + if (!PUBLISH_KEY_UUID.test(key)) { + throw Object.assign(new Error("\u65E0\u6548\u7684\u7528\u6237 ID"), { code: "invalid_page_path" }); + } + const clean = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== "..").join("/"); + if (!key || !clean || !clean.toLowerCase().endsWith(".html")) { + throw Object.assign(new Error("\u65E0\u6548\u7684\u9875\u9762\u8DEF\u5F84"), { code: "invalid_page_path" }); + } + const publishRoot = path19.resolve(h5Root, PUBLISH_ROOT_DIR, key); + const absolute = path19.resolve(publishRoot, clean); + if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path19.sep}`)) { + throw Object.assign(new Error("\u9875\u9762\u8DEF\u5F84\u8D8A\u754C"), { code: "invalid_page_path" }); + } + return absolute; +} +async function readPublishHtml(h5Root, userId, relativePath) { + const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath); + const content = await fs18.readFile(absolute, "utf8"); + if (!content.trim()) { + throw Object.assign(new Error("\u9875\u9762\u5185\u5BB9\u4E3A\u7A7A"), { code: "empty_page_content" }); + } + return { absolute, content, relativePath, filename: path19.basename(relativePath) }; +} +async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, depth = 0) { + if (depth > maxDepth || !basename.toLowerCase().endsWith(".html")) return null; + let entries; + try { + entries = await fs18.readdir(publishRoot, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = path19.join(publishRoot, entry.name); + if (entry.isFile() && entry.name === basename) return full; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue; + const found = await walkPublishHtmlByBasename( + path19.join(publishRoot, entry.name), + basename, + maxDepth, + depth + 1 + ); + if (found) return found; + } + return null; +} +async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth = 0) { + if (depth > maxDepth || results.length >= 200) return; + let entries; + try { + entries = await fs18.readdir(publishRoot, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = path19.join(publishRoot, entry.name); + if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) { + results.push(full); + if (results.length >= 200) return; + } + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue; + await collectPublishHtmlPaths(path19.join(publishRoot, entry.name), results, maxDepth, depth + 1); + if (results.length >= 200) return; + } +} +function levenshteinDistance(left, right) { + if (left === right) return 0; + if (!left) return right.length; + if (!right) return left.length; + const prev = Array.from({ length: right.length + 1 }, (_, index) => index); + const next = new Array(right.length + 1); + for (let i = 0; i < left.length; i += 1) { + next[0] = i + 1; + for (let j = 0; j < right.length; j += 1) { + const cost = left[i] === right[j] ? 0 : 1; + next[j + 1] = Math.min( + next[j] + 1, + prev[j + 1] + 1, + prev[j] + cost + ); + } + for (let j = 0; j <= right.length; j += 1) { + prev[j] = next[j]; + } + } + return prev[right.length]; +} +function htmlNameForSimilarity(relativePath) { + return path19.basename(String(relativePath ?? ""), ".html").toLowerCase(); +} +function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = Infinity) { + if (!requested || !candidate || requested === candidate) return false; + const longest = Math.max(requested.length, candidate.length); + const allowedDistance = longest >= 18 ? 2 : 1; + if (score > allowedDistance) return false; + return nextScore > score; +} +async function resolveClosestHtmlRelativePath(rootDir, relativePath) { + const normalized = normalizeStaticHtmlRelativePath(relativePath); + if (!normalized.toLowerCase().endsWith(".html")) return null; + const requestedName = htmlNameForSimilarity(normalized); + const candidates = []; + await collectPublishHtmlPaths(rootDir, candidates); + const ranked = candidates.map((absolute) => { + const candidateRelative = path19.relative(rootDir, absolute).split(path19.sep).join("/"); + const candidateName = htmlNameForSimilarity(candidateRelative); + return { + relativePath: candidateRelative, + score: levenshteinDistance(requestedName, candidateName) + }; + }).sort((left, right) => left.score - right.score || left.relativePath.localeCompare(right.relativePath)); + if (ranked.length === 0) return null; + const best = ranked[0]; + const nextBestScore = ranked[1]?.score ?? Infinity; + if (!isAcceptableSimilarHtmlMatch(requestedName, htmlNameForSimilarity(best.relativePath), best.score, nextBestScore)) { + return null; + } + return best.relativePath; +} +async function findPublishHtml(h5Root, userId, relativePath) { + const normalized = normalizeStaticHtmlRelativePath(relativePath); + const basename = path19.basename(normalized); + const candidates = [ + normalized, + path19.posix.join("public", basename), + basename + ].filter((value, index, list) => value && list.indexOf(value) === index); + for (const candidate of candidates) { + try { + return await readPublishHtml(h5Root, userId, candidate); + } catch { + } + } + const publishRoot = path19.resolve( + h5Root, + PUBLISH_ROOT_DIR, + String(userId ?? "").trim().toLowerCase() + ); + const absolute = await walkPublishHtmlByBasename(publishRoot, basename); + if (absolute) { + const resolvedRelativePath = path19.relative(publishRoot, absolute).split(path19.sep).join("/"); + return readPublishHtml(h5Root, userId, resolvedRelativePath); + } + const similarRelativePath = await resolveClosestHtmlRelativePath(publishRoot, normalized); + if (similarRelativePath) { + return readPublishHtml(h5Root, userId, similarRelativePath); + } + throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { code: "static_page_not_found" }); +} +function buildWorkspaceBaseHref(userId, htmlRelativePath) { + const key = String(userId ?? "").trim(); + const dir = path19.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); + const segments = dir === "." ? [] : dir.split("/").filter(Boolean); + const encoded = segments.map((part) => encodeURIComponent(part)).join("/"); + return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${encoded ? `${encoded}/` : ""}`; +} +function injectHtmlBaseHref(html, baseHref) { + const safeBase = String(baseHref ?? "").replace(/"/g, "%22"); + if (!safeBase) return html; + if (/]*href="[^"]*"[^>]*>/i, ``); + } + if (/]*>/i.test(html)) { + return html.replace(/]*>/i, (match) => `${match} +`); + } + return `${html}`; +} +function buildChatSavePreviewQuery({ + sessionId, + messageId, + selectedLinkIndex = 0, + previewTitle, + previewSummary +} = {}) { + const params = new URLSearchParams({ + session_id: String(sessionId ?? ""), + message_id: String(messageId ?? ""), + selected_link_index: String(selectedLinkIndex) + }); + const title = String(previewTitle ?? "").trim(); + const summary = String(previewSummary ?? "").trim(); + if (title) params.set("preview_title", title); + if (summary) params.set("preview_summary", summary); + return params; +} +function buildChatSavePreviewFrameUrl(input) { + return `/api/mindspace/v1/pages/chat-save-preview?${buildChatSavePreviewQuery(input).toString()}`; +} +function buildChatSaveThumbnailUrl(input) { + return `/api/mindspace/v1/pages/chat-save-thumbnail?${buildChatSavePreviewQuery(input).toString()}`; +} +function titleFromHtml2(html) { + const match = String(html).match(/]*>([^<]+)<\/title>/i); + return match?.[1]?.trim() ?? ""; +} +function summaryFromHtml(html) { + const stripped = String(html).replace(//gi, " ").replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + return stripped.slice(0, 180); +} +function dedupeDuplicatedFilename(filename) { + const match = String(filename ?? "").match(/^(.+)\.html$/i); + if (!match) return filename; + let base = match[1]; + const half = Math.floor(base.length / 2); + if (half >= 2 && base.slice(0, half) === base.slice(half)) { + base = base.slice(0, half); + } + base = base.replace(/(.+?)\1+/g, "$1"); + base = base.replace(/-v-v(\d+)/g, "-v$1"); + return `${base}.html`; +} +function sanitizeMessageContentForSave(content) { + let text = String(content ?? ""); + text = text.replace(/httpshttps:\/+/gi, "https://"); + text = text.replace(/https:(?:\/\/)+/gi, "https://"); + text = text.replace(/(https?:\/\/[^\s<>"')\]]+?)(?:\1)+/gi, "$1"); + text = text.replace(/([\u4e00-\u9fffA-Za-z0-9._-]+)\s+\1/g, "$1"); + text = text.replace(/(\.html)\.html\b/gi, ".html"); + text = text.replace(/((?:MindSpace|temp)\/[^/\s<>"')\]]+\/)(public\/)\2/gi, "$1$2"); + return text; +} +function extractHtmlFilenameHints(content) { + const hints = /* @__PURE__ */ new Set(); + for (const match of String(content ?? "").matchAll(/([a-z0-9][a-z0-9._-]*\.html)/gi)) { + hints.add(dedupeDuplicatedFilename(match[1].toLowerCase())); + } + return [...hints]; +} +function analyzeChatMessageForSave({ + content, + userId, + username, + h5Root, + selectedLinkIndex = 0 +}) { + const sanitized = sanitizeMessageContentForSave(content); + const links = extractStaticPageLinks(sanitized, { userId, username }); + const text = sanitized.replace(/\s+/g, " ").trim(); + const suggestedTitleFromText = text.replace(/^#{1,6}\s*/, "").replace(/[*_`~[\]]/g, "").trim().slice(0, 48); + if (links.length === 0) { + return { + contentMode: "markdown", + links: [], + selectedLink: null, + suggestedTitle: suggestedTitleFromText || "AI \u521B\u4F5C\u9875\u9762", + suggestedSummary: text.slice(0, 160), + previewUrl: null, + relativePath: null, + filename: null + }; + } + const index = Math.min(Math.max(0, selectedLinkIndex), links.length - 1); + const selectedLink = links[index]; + return { + contentMode: "static_html", + links, + selectedLink, + suggestedTitle: selectedLink.filename.replace(/\.html$/i, "").replace(/[-_]/g, " ") || suggestedTitleFromText || "AI \u751F\u6210\u9875\u9762", + suggestedSummary: text.slice(0, 160), + previewUrl: selectedLink.publicUrl, + relativePath: selectedLink.relativePath, + filename: selectedLink.filename, + h5Root, + userId, + username + }; +} +async function resolveStaticHtmlContent(analysis) { + if (analysis.contentMode !== "static_html" || !analysis.selectedLink) { + return null; + } + const loaded = await findPublishHtml( + analysis.h5Root, + analysis.userId, + analysis.selectedLink.relativePath + ); + const title = titleFromHtml2(loaded.content); + const summary = summaryFromHtml(loaded.content); + return { + ...loaded, + suggestedTitle: title || analysis.suggestedTitle, + suggestedSummary: summary || analysis.suggestedSummary, + publicUrl: analysis.selectedLink.publicUrl + }; +} +async function resolveChatSaveAnalysis({ + content, + userId, + username, + h5Root, + selectedLinkIndex = 0 +}) { + const sanitized = sanitizeMessageContentForSave(content); + let analysis = analyzeChatMessageForSave({ + content: sanitized, + userId, + username, + h5Root, + selectedLinkIndex + }); + if (analysis.contentMode === "static_html" && analysis.selectedLink) { + try { + const resolvedHtml = await resolveStaticHtmlContent(analysis); + return { analysis, resolvedHtml }; + } catch { + } + } + for (const hint of extractHtmlFilenameHints(sanitized)) { + try { + const loaded = await findPublishHtml(h5Root, userId, hint); + const syntheticLink = { + publicUrl: buildWorkspaceAssetUrl(userId, loaded.relativePath) ?? `/${PUBLISH_ROOT_DIR}/${userId}/${loaded.relativePath}`, + owner: String(userId ?? "").trim().toLowerCase(), + relativePath: loaded.relativePath, + filename: loaded.filename + }; + analysis = { + contentMode: "static_html", + links: [syntheticLink], + selectedLink: syntheticLink, + suggestedTitle: titleFromHtml2(loaded.content) || syntheticLink.filename.replace(/\.html$/i, "").replace(/[-_]/g, " ") || "AI \u751F\u6210\u9875\u9762", + suggestedSummary: summaryFromHtml(loaded.content) || sanitized.slice(0, 160), + previewUrl: syntheticLink.publicUrl, + relativePath: loaded.relativePath, + filename: loaded.filename, + h5Root, + userId, + username + }; + return { + analysis, + resolvedHtml: { + ...loaded, + suggestedTitle: analysis.suggestedTitle, + suggestedSummary: analysis.suggestedSummary, + publicUrl: syntheticLink.publicUrl + } + }; + } catch { + } + } + return { analysis, resolvedHtml: null }; +} + +// mindspace-og-tags.mjs +function rawTitleFromHtml(html) { + return String(html).match(/]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? ""; +} +function escapeAttr(value) { + return String(value).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">"); +} +function resolveImageUrl(image, { origin, pageDirUrl }) { + if (!image) return null; + const trimmed = String(image).trim(); + if (!trimmed || trimmed.startsWith("data:")) return null; + if (/\.svg(?:[?#]|$)/i.test(trimmed)) return null; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (trimmed.startsWith("//")) return `https:${trimmed}`; + if (trimmed.startsWith("/")) return `${origin}${trimmed}`; + return `${pageDirUrl}${trimmed}`; +} +function injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl = "" }) { + const source = String(html); + if (/]+property=["']og:image["']/i.test(source)) return source; + const signals = extractCoverSignals(source); + const title = rawTitleFromHtml(source) || signals.title; + const description = signals.subtitle || ""; + const imageUrl = resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || null; + const tags = [ + '', + pageUrl ? `` : "", + title ? `` : "", + description ? `` : "", + imageUrl ? `` : "", + ``, + title ? `` : "", + description ? `` : "", + imageUrl ? `` : "" + ].filter(Boolean); + const block = ` +${tags.map((t) => ` ${t}`).join("\n")} +`; + if (/<\/head>/i.test(source)) { + return source.replace(/<\/head>/i, `${block}`); + } + if (/]*>/i.test(source)) { + return source.replace(/(]*>)/i, `$1${block}`); + } + return source; +} + +// mindspace-thumbnail-png.mjs +import fs19 from "node:fs"; +import { Resvg } from "@resvg/resvg-js"; +var RENDER_WIDTH = 540; +function rasterizeThumbnailSvgToPng(svg) { + const resvg = new Resvg(svg, { + background: "white", + fitTo: { mode: "width", value: RENDER_WIDTH }, + font: { loadSystemFonts: true } + // CJK + serif via the host font stack (PingFang/Georgia on macOS) + }); + return resvg.render().asPng(); +} +function thumbnailPngPathForSvg(svgAbsPath) { + return svgAbsPath.replace(/\.svg$/i, ".png"); +} +function ensureThumbnailPng(svgAbsPath) { + if (!fs19.existsSync(svgAbsPath)) return null; + const pngPath = thumbnailPngPathForSvg(svgAbsPath); + try { + const svgStat = fs19.statSync(svgAbsPath); + if (fs19.existsSync(pngPath) && fs19.statSync(pngPath).mtimeMs >= svgStat.mtimeMs) { + return pngPath; + } + const svg = fs19.readFileSync(svgAbsPath, "utf8"); + fs19.writeFileSync(pngPath, rasterizeThumbnailSvgToPng(svg)); + return pngPath; + } catch { + return fs19.existsSync(pngPath) ? pngPath : null; + } +} + +// billing-subscription.mjs +import crypto23 from "node:crypto"; +var PLAN_CATALOG = { + free: { + name: "\u514D\u8D39\u7248", + priceCents: 0, + periodTokens: 15e4, + periodImages: 10, + modelTier: "basic", + overageRate: 1, + periodDays: 30 + }, + lite: { + name: "\u8F7B\u91CF\u7248", + priceCents: 990, + periodTokens: 12e5, + periodImages: 50, + modelTier: "basic", + overageRate: 0.5, + periodDays: 30 + }, + standard: { + name: "\u6807\u51C6\u7248", + priceCents: 2900, + periodTokens: 45e5, + periodImages: 200, + modelTier: "standard", + overageRate: 0.7, + periodDays: 30 + }, + pro: { + name: "\u4E13\u4E1A\u7248", + priceCents: 7900, + periodTokens: 0, + periodImages: 0, + modelTier: "premium", + overageRate: 0.8, + periodDays: 30 + } +}; +function getPlanDef(planType) { + return PLAN_CATALOG[planType] ?? null; +} +var PLAN_ORDER = Object.fromEntries( + Object.keys(PLAN_CATALOG).map((key, i) => [key, i]) +); +function mapSubRow(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + planType: row.plan_type, + status: row.status, + periodTokensLimit: Number(row.period_tokens_limit ?? 0), + periodTokensUsed: Number(row.period_tokens_used ?? 0), + periodImagesLimit: Number(row.period_images_limit ?? 0), + periodImagesUsed: Number(row.period_images_used ?? 0), + periodStart: Number(row.period_start), + periodEnd: Number(row.period_end), + expiresAt: Number(row.expires_at), + overageRate: Number(row.overage_rate ?? 1), + autoRenew: Boolean(row.auto_renew), + operatorId: row.operator_id ?? null, + note: row.note ?? null, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function createSubscriptionService(pool, { getPlanAsync = null } = {}) { + const resolvePlan = async (planType) => { + if (getPlanAsync) { + const p2 = await getPlanAsync(planType); + if (p2) return p2; + } + const p = getPlanDef(planType); + return p ? { ...p, periodImages: p.periodImages ?? 0 } : null; + }; + const getActiveSubscription = async (userId) => { + const now = Date.now(); + const [rows] = await pool.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1`, + [userId, now] + ); + return mapSubRow(rows[0]); + }; + const grantSubscription = async (userId, planType, durationDays, operatorId = null, note = "") => { + const plan = await resolvePlan(planType); + if (!plan) return { ok: false, message: `\u672A\u77E5\u5957\u9910\u7C7B\u578B: ${planType}` }; + const now = Date.now(); + const periodDays = durationDays ?? plan.periodDays; + const periodEnd = now + periodDays * 24 * 60 * 60 * 1e3; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND status = 'active'`, + [now, userId] + ); + const id = crypto23.randomUUID(); + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, + [ + id, + userId, + planType, + plan.periodTokens, + plan.periodImages ?? 0, + now, + periodEnd, + periodEnd, + Number(plan.overageRate.toFixed(2)), + operatorId, + note || null, + now, + now + ] + ); + await conn.query( + `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, + [planType, now, userId] + ); + await conn.commit(); + const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); + return { ok: true, subscription: mapSubRow(rows[0]) }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + const consumeQuota = async (userId, deltaTokens, conn) => { + if (!deltaTokens || deltaTokens <= 0) { + return { fullyCovers: true, overageRate: 1 }; + } + const now = Date.now(); + const [rows] = await conn.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1 + FOR UPDATE`, + [userId, now] + ); + const sub = mapSubRow(rows[0]); + if (!sub) return { fullyCovers: false, overageRate: 1 }; + const unlimited = sub.periodTokensLimit === 0; + const remaining = unlimited ? Infinity : sub.periodTokensLimit - sub.periodTokensUsed; + if (unlimited || remaining >= deltaTokens) { + await conn.query( + `UPDATE h5_subscriptions + SET period_tokens_used = period_tokens_used + ?, updated_at = ? + WHERE id = ?`, + [deltaTokens, now, sub.id] + ); + return { fullyCovers: true, overageRate: sub.overageRate }; + } + if (remaining > 0) { + await conn.query( + `UPDATE h5_subscriptions + SET period_tokens_used = period_tokens_limit, updated_at = ? + WHERE id = ?`, + [now, sub.id] + ); + } + return { fullyCovers: false, overageRate: sub.overageRate }; + }; + const renewSubscription = async (subId, operatorId = null) => { + const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [subId]); + const sub = mapSubRow(rows[0]); + if (!sub) return { ok: false, message: "\u8BA2\u9605\u4E0D\u5B58\u5728" }; + const plan = await resolvePlan(sub.planType); + if (!plan) return { ok: false, message: "\u5957\u9910\u5B9A\u4E49\u5DF2\u5931\u6548" }; + const now = Date.now(); + const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const newId = crypto23.randomUUID(); + await conn.query( + `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`, + [now, subId] + ); + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newId, + sub.userId, + sub.planType, + plan.periodTokens, + plan.periodImages ?? 0, + now, + newPeriodEnd, + newPeriodEnd, + Number(plan.overageRate.toFixed(2)), + sub.autoRenew ? 1 : 0, + operatorId, + "\u81EA\u52A8\u7EED\u8BA2", + now, + now + ] + ); + await conn.commit(); + const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]); + return { ok: true, subscription: mapSubRow(newRows[0]) }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + const expireStaleSubscriptions = async () => { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? + WHERE status = 'active' AND expires_at <= ?`, + [now, now] + ); + if (result.affectedRows > 0) { + await pool.query( + `UPDATE h5_users u + LEFT JOIN h5_subscriptions s + ON s.user_id = u.id AND s.status = 'active' AND s.expires_at > ? + SET u.plan_type = 'free', u.updated_at = ? + WHERE u.plan_type != 'free' AND s.id IS NULL`, + [now, now] + ); + } + return result.affectedRows; + }; + const cancelSubscription = async (userId, operatorId = null) => { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND status = 'active'`, + [now, userId] + ); + if (result.affectedRows > 0) { + await pool.query( + `UPDATE h5_users SET plan_type = 'free', updated_at = ? WHERE id = ?`, + [now, userId] + ); + } + return { ok: true, cancelled: result.affectedRows > 0 }; + }; + const listSubscriptions = async ({ userId = null, status = null, page = 1, pageSize = 20 } = {}) => { + const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safePageSize; + const params = []; + const clauses = []; + if (userId) { + clauses.push("s.user_id = ?"); + params.push(userId); + } + if (status) { + clauses.push("s.status = ?"); + params.push(status); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const [[{ total }]] = await pool.query( + `SELECT COUNT(*) AS total FROM h5_subscriptions s ${where}`, + params + ); + const [rows] = await pool.query( + `SELECT s.*, u.username, u.display_name + FROM h5_subscriptions s JOIN h5_users u ON u.id = s.user_id + ${where} ORDER BY s.created_at DESC LIMIT ${safePageSize} OFFSET ${offset}`, + params + ); + return { + total: Number(total), + page: safePage, + pageSize: safePageSize, + items: rows.map((r) => ({ + ...mapSubRow(r), + username: r.username, + displayName: r.display_name + })) + }; + }; + const purchaseSubscription = async (userId, planType, autoRenew = false) => { + const plan = await resolvePlan(planType); + if (!plan || plan.priceCents === 0) { + return { ok: false, message: "\u65E0\u6548\u5957\u9910\u6216\u514D\u8D39\u5957\u9910\u4E0D\u53EF\u8D2D\u4E70" }; + } + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [currentSubRows] = await conn.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, + [userId, now] + ); + const currentSub = mapSubRow(currentSubRows[0]); + if (currentSub) { + const currentOrder = PLAN_ORDER[currentSub.planType] ?? 0; + const newOrder = PLAN_ORDER[planType] ?? 0; + if (newOrder < currentOrder) { + await conn.rollback(); + return { + ok: false, + code: "DOWNGRADE_NOT_ALLOWED", + message: "\u5F53\u524D\u5957\u9910\u6709\u6548\u671F\u5185\u4E0D\u53EF\u964D\u7EA7\uFF0C\u5230\u671F\u540E\u5C06\u81EA\u52A8\u964D\u4E3A\u514D\u8D39\u7248", + currentPlanType: currentSub.planType + }; + } + } + const [walletRows] = await conn.query( + `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, + [userId] + ); + const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); + if (balanceCents < plan.priceCents) { + await conn.rollback(); + return { + ok: false, + code: "INSUFFICIENT_BALANCE", + message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u8BA2\u9605", + balanceCents, + requiredCents: plan.priceCents, + shortfallCents: plan.priceCents - balanceCents + }; + } + await conn.query( + `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, + [plan.priceCents, now, userId] + ); + await conn.query( + `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, + [userId, plan.priceCents, `subscription:${planType}`, now] + ); + await conn.query( + `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND status = 'active'`, + [now, userId] + ); + const id = crypto23.randomUUID(); + const periodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, NULL, '\u7528\u6237\u81EA\u52A9\u8D2D\u4E70', ?, ?)`, + [ + id, + userId, + planType, + plan.periodTokens, + plan.periodImages ?? 0, + now, + periodEnd, + periodEnd, + Number(plan.overageRate.toFixed(2)), + autoRenew ? 1 : 0, + now, + now + ] + ); + await conn.query( + `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, + [planType, now, userId] + ); + await conn.commit(); + const [subRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); + return { + ok: true, + subscription: mapSubRow(subRows[0]), + balanceCents: balanceCents - plan.priceCents + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + const setAutoRenew = async (userId, enabled) => { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_subscriptions SET auto_renew = ?, updated_at = ? + WHERE user_id = ? AND status = 'active' AND expires_at > ?`, + [enabled ? 1 : 0, now, userId, now] + ); + return { ok: true, updated: result.affectedRows > 0 }; + }; + const processAutoRenewals = async () => { + const now = Date.now(); + const [rows] = await pool.query( + `SELECT s.*, w.balance_cents + FROM h5_subscriptions s + JOIN h5_user_wallets w ON w.user_id = s.user_id + WHERE s.status = 'active' AND s.auto_renew = 1 AND s.expires_at <= ?`, + [now] + ); + let renewed = 0; + let failed = 0; + for (const row of rows) { + const sub = mapSubRow(row); + const plan = getPlanDef(sub.planType); + if (!plan || plan.priceCents === 0) continue; + const balance = Number(row.balance_cents ?? 0); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [walletRows] = await conn.query( + `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, + [sub.userId] + ); + const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); + if (balanceCents < plan.priceCents) { + await conn.rollback(); + failed++; + console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`); + continue; + } + await conn.query( + `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, + [plan.priceCents, now, sub.userId] + ); + await conn.query( + `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, + [sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now] + ); + await conn.query( + `UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`, + [now, sub.id] + ); + const newId = crypto23.randomUUID(); + const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 1, NULL, '\u81EA\u52A8\u7EED\u8D39', ?, ?)`, + [ + newId, + sub.userId, + sub.planType, + plan.periodTokens, + plan.periodImages ?? 0, + now, + newPeriodEnd, + newPeriodEnd, + Number(plan.overageRate.toFixed(2)), + now, + now + ] + ); + await conn.query( + `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, + [sub.planType, now, sub.userId] + ); + await conn.commit(); + renewed++; + console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`); + } catch (err) { + await conn.rollback(); + console.warn(`Auto-renew error for user ${sub.userId}:`, err); + failed++; + } finally { + conn.release(); + } + } + return { renewed, failed }; + }; + const consumeImageQuotaTx = async (userId, count, conn) => { + if (!count || count <= 0) return { fullyCovers: true }; + const now = Date.now(); + const [rows] = await conn.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, + [userId, now] + ); + const sub = mapSubRow(rows[0]); + if (!sub) return { fullyCovers: false }; + const unlimited = sub.periodImagesLimit === 0; + const remaining = unlimited ? Infinity : sub.periodImagesLimit - sub.periodImagesUsed; + if (unlimited || remaining >= count) { + await conn.query( + `UPDATE h5_subscriptions + SET period_images_used = period_images_used + ?, updated_at = ? + WHERE id = ?`, + [count, now, sub.id] + ); + return { fullyCovers: true }; + } + return { fullyCovers: false }; + }; + const consumeImageQuota = async (userId, count, conn = null) => { + if (conn) return consumeImageQuotaTx(userId, count, conn); + const ownConn = await pool.getConnection(); + try { + await ownConn.beginTransaction(); + const result = await consumeImageQuotaTx(userId, count, ownConn); + await ownConn.commit(); + return result; + } catch (err) { + await ownConn.rollback(); + throw err; + } finally { + ownConn.release(); + } + }; + return { + getActiveSubscription, + grantSubscription, + purchaseSubscription, + setAutoRenew, + processAutoRenewals, + consumeQuota, + consumeImageQuota, + renewSubscription, + expireStaleSubscriptions, + cancelSubscription, + listSubscriptions + }; +} +async function ensurePlanCatalogSchema(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_plan_catalog ( + plan_type VARCHAR(32) PRIMARY KEY, + name VARCHAR(64) NOT NULL, + price_cents INT NOT NULL DEFAULT 0, + period_days INT NOT NULL DEFAULT 30, + period_tokens BIGINT NOT NULL DEFAULT 0, + period_images INT NOT NULL DEFAULT 0, + model_tier VARCHAR(32) NOT NULL DEFAULT 'basic', + overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00, + sort_order INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + description VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + for (const col of [ + "ALTER TABLE h5_subscriptions ADD COLUMN period_images_limit INT NOT NULL DEFAULT 0", + "ALTER TABLE h5_subscriptions ADD COLUMN period_images_used INT NOT NULL DEFAULT 0" + ]) { + try { + await pool.query(col); + } catch (_) { + } + } + const [[{ cnt }]] = await pool.query(`SELECT COUNT(*) AS cnt FROM h5_plan_catalog`); + if (Number(cnt) === 0) { + const now = Date.now(); + const entries = Object.entries(PLAN_CATALOG); + for (const [i, [planType, plan]] of entries.entries()) { + await pool.query( + `INSERT IGNORE INTO h5_plan_catalog + (plan_type, name, price_cents, period_days, period_tokens, period_images, + model_tier, overage_rate, sort_order, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, + [ + planType, + plan.name, + plan.priceCents, + plan.periodDays, + plan.periodTokens, + plan.periodImages ?? 0, + plan.modelTier, + plan.overageRate, + i, + now, + now + ] + ); + } + } +} +function createPlanCatalogService(pool) { + function mapPlanRow(row) { + return { + planType: row.plan_type, + name: row.name, + priceCents: Number(row.price_cents), + periodDays: Number(row.period_days), + periodTokens: Number(row.period_tokens), + periodImages: Number(row.period_images), + modelTier: row.model_tier, + overageRate: Number(row.overage_rate), + sortOrder: Number(row.sort_order), + isActive: Boolean(row.is_active), + description: row.description ?? null + }; + } + const listPlans = async ({ includeInactive = true } = {}) => { + const where = includeInactive ? "" : "WHERE is_active = 1"; + const [rows] = await pool.query( + `SELECT * FROM h5_plan_catalog ${where} ORDER BY sort_order ASC, price_cents ASC` + ); + return rows.map(mapPlanRow); + }; + const getPlan = async (planType) => { + const [rows] = await pool.query( + `SELECT * FROM h5_plan_catalog WHERE plan_type = ?`, + [planType] + ); + return rows[0] ? mapPlanRow(rows[0]) : null; + }; + const upsertPlan = async (planType, data) => { + if (!planType || !/^[a-z0-9_]{1,32}$/.test(planType)) { + return { ok: false, message: "\u5957\u9910\u6807\u8BC6\u53EA\u80FD\u4F7F\u7528\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u548C\u4E0B\u5212\u7EBF\uFF0C\u6700\u957F32\u4F4D" }; + } + const now = Date.now(); + await pool.query( + `INSERT INTO h5_plan_catalog + (plan_type, name, price_cents, period_days, period_tokens, period_images, + model_tier, overage_rate, sort_order, is_active, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), price_cents = VALUES(price_cents), + period_days = VALUES(period_days), period_tokens = VALUES(period_tokens), + period_images = VALUES(period_images), model_tier = VALUES(model_tier), + overage_rate = VALUES(overage_rate), sort_order = VALUES(sort_order), + is_active = VALUES(is_active), description = VALUES(description), + updated_at = VALUES(updated_at)`, + [ + planType, + String(data.name ?? "").trim() || planType, + Math.max(0, Math.round(Number(data.priceCents ?? 0))), + Math.max(1, Math.round(Number(data.periodDays ?? 30))), + Math.max(0, Math.round(Number(data.periodTokens ?? 0))), + Math.max(0, Math.round(Number(data.periodImages ?? 0))), + ["basic", "standard", "premium"].includes(data.modelTier) ? data.modelTier : "basic", + Math.min(2, Math.max(0, Number((data.overageRate ?? 1).toFixed(2)))), + Math.round(Number(data.sortOrder ?? 0)), + data.isActive !== false ? 1 : 0, + data.description ? String(data.description).trim() : null, + now, + now + ] + ); + return { ok: true, plan: await getPlan(planType) }; + }; + const deletePlan = async (planType) => { + if (planType === "free") return { ok: false, message: "\u514D\u8D39\u5957\u9910\u4E0D\u53EF\u5220\u9664" }; + const [result] = await pool.query( + `DELETE FROM h5_plan_catalog WHERE plan_type = ?`, + [planType] + ); + return { ok: true, deleted: result.affectedRows > 0 }; + }; + return { listPlans, getPlan, upsertPlan, deletePlan }; +} + +// wechat-pay.mjs +import crypto24 from "node:crypto"; +import fs20 from "node:fs"; +import { fetch as fetch2 } from "undici"; +var API_BASE = "https://api.mch.weixin.qq.com"; +function readPrivateKey(config) { + if (config.privateKeyPem) return config.privateKeyPem; + if (config.privateKeyPath && fs20.existsSync(config.privateKeyPath)) { + return fs20.readFileSync(config.privateKeyPath, "utf8"); + } + return null; +} +function readPlatformCert(config) { + if (config.platformCertPem) return config.platformCertPem; + if (config.platformCertPath && fs20.existsSync(config.platformCertPath)) { + return fs20.readFileSync(config.platformCertPath, "utf8"); + } + return null; +} +function resolveNotifyUrl() { + const notifyUrl = process.env.H5_WECHAT_NOTIFY_URL?.trim() ?? ""; + const publicBaseUrl = process.env.H5_PUBLIC_BASE_URL?.trim() ?? ""; + return notifyUrl || (publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, "")}/webhooks/wechat-pay/notify` : ""); +} +function detectApiVersion({ explicitVersion, apiV2Key, v3Ready }) { + const normalized = explicitVersion?.trim().toLowerCase(); + if (normalized === "v2" || normalized === "2") return "v2"; + if (normalized === "v3" || normalized === "3") return "v3"; + if (v3Ready) return "v3"; + if (apiV2Key) return "v2"; + return null; +} +function loadWechatPayConfig() { + const enabled = process.env.H5_WECHAT_PAY_ENABLED === "1"; + const appId = process.env.H5_WECHAT_APP_ID?.trim() ?? ""; + const mchId = process.env.H5_WECHAT_MCH_ID?.trim() ?? ""; + const apiV2Key = process.env.H5_WECHAT_API_V2_KEY?.trim() ?? process.env.H5_WECHAT_API_KEY?.trim() ?? ""; + const apiV3Key = process.env.H5_WECHAT_API_V3_KEY?.trim() ?? ""; + const serialNo = process.env.H5_WECHAT_SERIAL_NO?.trim() ?? ""; + const privateKeyPath = process.env.H5_WECHAT_PRIVATE_KEY_PATH?.trim() ?? ""; + const privateKeyPem = process.env.H5_WECHAT_PRIVATE_KEY?.replace(/\\n/g, "\n").trim() ?? ""; + const platformCertPath = process.env.H5_WECHAT_PLATFORM_CERT_PATH?.trim() ?? ""; + const platformCertPem = process.env.H5_WECHAT_PLATFORM_CERT?.replace(/\\n/g, "\n").trim() ?? ""; + const resolvedNotifyUrl = resolveNotifyUrl(); + const skipNotifyVerify = process.env.H5_WECHAT_SKIP_NOTIFY_VERIFY === "1"; + const privateKey = readPrivateKey({ privateKeyPath, privateKeyPem }); + const platformCert = readPlatformCert({ platformCertPath, platformCertPem }); + const v3Ready = Boolean(appId && mchId && apiV3Key && serialNo && privateKey && resolvedNotifyUrl); + const apiVersion = detectApiVersion({ + explicitVersion: process.env.H5_WECHAT_API_VERSION, + apiV2Key, + v3Ready: Boolean(apiV3Key && serialNo && privateKey) + }); + if (apiVersion === "v2") { + const configured2 = Boolean(appId && mchId && apiV2Key && resolvedNotifyUrl); + return { + enabled: enabled && configured2, + apiVersion: "v2", + appId, + mchId, + apiKey: apiV2Key, + notifyUrl: resolvedNotifyUrl, + skipNotifyVerify + }; + } + const configured = v3Ready; + return { + enabled: enabled && configured, + apiVersion: configured ? "v3" : apiVersion, + appId, + mchId, + apiV3Key, + serialNo, + privateKey, + platformCert, + notifyUrl: resolvedNotifyUrl, + skipNotifyVerify + }; +} +function randomNonce(length = 32) { + return crypto24.randomBytes(length).toString("hex").slice(0, length); +} +function signV2Params(params, apiKey) { + const stringA = Object.keys(params).filter((key) => key !== "sign" && params[key] !== void 0 && params[key] !== "").sort().map((key) => `${key}=${params[key]}`).join("&"); + const stringSignTemp = `${stringA}&key=${apiKey}`; + return crypto24.createHash("md5").update(stringSignTemp, "utf8").digest("hex").toUpperCase(); +} +function buildXml(fields) { + const parts = [""]; + for (const [key, value] of Object.entries(fields)) { + if (value === void 0 || value === "") continue; + parts.push(`<${key}>`); + } + parts.push(""); + return parts.join(""); +} +function parseXmlFields(xml) { + const result = {}; + const re = /<(\w+)>(?:|([^<]*))<\/\1>/g; + let match = re.exec(xml); + while (match) { + result[match[1]] = match[2] ?? match[3] ?? ""; + match = re.exec(xml); + } + return result; +} +function normalizeV2Transaction(fields) { + return { + out_trade_no: fields.out_trade_no, + transaction_id: fields.transaction_id, + trade_state: fields.result_code === "SUCCESS" ? "SUCCESS" : fields.result_code, + result_code: fields.result_code, + return_code: fields.return_code, + total_fee: fields.total_fee ? Number(fields.total_fee) : void 0, + amount: fields.total_fee ? { total: Number(fields.total_fee) } : void 0 + }; +} +async function wechatV2Request(config, path23, fields) { + const payload = { + appid: config.appId, + mch_id: config.mchId, + nonce_str: randomNonce(), + ...fields + }; + payload.sign = signV2Params(payload, config.apiKey); + const body = buildXml(payload); + const res = await fetch2(`${API_BASE}${path23}`, { + method: "POST", + headers: { "Content-Type": "text/xml" }, + body + }); + const text = await res.text(); + const data = parseXmlFields(text); + if (!res.ok) { + throw new Error(`\u5FAE\u4FE1\u652F\u4ED8\u8BF7\u6C42\u5931\u8D25 (${res.status}): ${text}`); + } + if (data.return_code !== "SUCCESS") { + throw new Error(`\u5FAE\u4FE1\u652F\u4ED8\u8BF7\u6C42\u5931\u8D25: ${data.return_msg || data.return_code || text}`); + } + if (data.result_code !== "SUCCESS") { + throw new Error(`\u5FAE\u4FE1\u652F\u4ED8\u4E0B\u5355\u5931\u8D25: ${data.err_code_des || data.err_code || text}`); + } + return data; +} +function signMessage(message, privateKeyPem) { + return crypto24.createSign("RSA-SHA256").update(message).sign(privateKeyPem, "base64"); +} +function buildAuthorization({ mchId, serialNo, privateKey }, method, pathWithQuery, body) { + const timestamp = String(Math.floor(Date.now() / 1e3)); + const nonce = randomNonce(); + const payload = body ?? ""; + const message = `${method} +${pathWithQuery} +${timestamp} +${nonce} +${payload} +`; + const signature = signMessage(message, privateKey); + const authorization = [ + "WECHATPAY2-SHA256-RSA2048", + `mchid="${mchId}"`, + `nonce_str="${nonce}"`, + `signature="${signature}"`, + `timestamp="${timestamp}"`, + `serial_no="${serialNo}"` + ].join(","); + return { authorization, timestamp, nonce, signature }; +} +async function wechatV3Request(config, method, path23, bodyObj) { + const body = bodyObj ? JSON.stringify(bodyObj) : ""; + const { authorization } = buildAuthorization(config, method, path23, body); + const res = await fetch2(`${API_BASE}${path23}`, { + method, + headers: { + Authorization: authorization, + Accept: "application/json", + "Content-Type": "application/json" + }, + body: body || void 0 + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!res.ok) { + const detail = data?.message ?? data?.detail ?? text; + throw new Error(`\u5FAE\u4FE1\u652F\u4ED8\u8BF7\u6C42\u5931\u8D25 (${res.status}): ${detail}`); + } + return data; +} +function buildV2JsapiParams(config, prepayId) { + const timeStamp = String(Math.floor(Date.now() / 1e3)); + const nonceStr = randomNonce(16); + const pkg = `prepay_id=${prepayId}`; + const signType = "MD5"; + const paySign = signV2Params( + { + appId: config.appId, + timeStamp, + nonceStr, + package: pkg, + signType + }, + config.apiKey + ); + return { appId: config.appId, timeStamp, nonceStr, package: pkg, signType, paySign }; +} +function buildV3JsapiParams(config, prepayId) { + const timeStamp = String(Math.floor(Date.now() / 1e3)); + const nonceStr = randomNonce(16); + const pkg = `prepay_id=${prepayId}`; + const message = `${config.appId} +${timeStamp} +${nonceStr} +${pkg} +`; + const paySign = signMessage(message, config.privateKey); + return { appId: config.appId, timeStamp, nonceStr, package: pkg, signType: "RSA", paySign }; +} +function createV2Client(config) { + const createNativeOrder = async ({ outTradeNo, description, amountCents, clientIp }) => { + const data = await wechatV2Request(config, "/pay/unifiedorder", { + body: description, + out_trade_no: outTradeNo, + total_fee: String(amountCents), + spbill_create_ip: clientIp || "127.0.0.1", + notify_url: config.notifyUrl, + trade_type: "NATIVE" + }); + if (!data.code_url) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u8FD4\u56DE\u4E8C\u7EF4\u7801\u94FE\u63A5"); + } + return { codeUrl: data.code_url }; + }; + const createH5Order = async ({ outTradeNo, description, amountCents, clientIp }) => { + const appUrl = process.env.H5_PUBLIC_BASE_URL ?? "https://go.tkmind.cn"; + const data = await wechatV2Request(config, "/pay/unifiedorder", { + body: description, + out_trade_no: outTradeNo, + total_fee: String(amountCents), + spbill_create_ip: clientIp || "127.0.0.1", + notify_url: config.notifyUrl, + trade_type: "MWEB", + scene_info: JSON.stringify({ + h5_info: { + type: "Wap", + wap_url: appUrl, + wap_name: "TKMind" + } + }) + }); + if (!data.mweb_url) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u8FD4\u56DE H5 \u652F\u4ED8\u94FE\u63A5"); + } + return { h5Url: data.mweb_url }; + }; + const createJsapiOrder = async ({ outTradeNo, description, amountCents, clientIp, openid }) => { + if (!openid) { + throw new Error("JSAPI \u652F\u4ED8\u7F3A\u5C11 openid"); + } + const data = await wechatV2Request(config, "/pay/unifiedorder", { + body: description, + out_trade_no: outTradeNo, + total_fee: String(amountCents), + spbill_create_ip: clientIp || "127.0.0.1", + notify_url: config.notifyUrl, + trade_type: "JSAPI", + openid + }); + if (!data.prepay_id) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u8FD4\u56DE prepay_id"); + } + return { jsapiParams: buildV2JsapiParams(config, data.prepay_id) }; + }; + const verifyNotify = ({ headers: _headers, body }) => { + const bodyText = typeof body === "string" ? body : String(body ?? ""); + const fields = parseXmlFields(bodyText); + if (!fields.out_trade_no) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u7F3A\u5C11\u5546\u6237\u8BA2\u5355\u53F7"); + } + if (fields.return_code !== "SUCCESS") { + throw new Error(`\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u5931\u8D25: ${fields.return_msg || fields.return_code}`); + } + if (!config.skipNotifyVerify) { + const sign = fields.sign; + if (!sign) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u7F3A\u5C11\u7B7E\u540D"); + } + const expected = signV2Params(fields, config.apiKey); + if (sign !== expected) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u9A8C\u7B7E\u5931\u8D25"); + } + } + return { transaction: normalizeV2Transaction(fields) }; + }; + return { + enabled: true, + apiVersion: "v2", + appId: config.appId, + createNativeOrder, + createH5Order, + createJsapiOrder, + verifyNotify + }; +} +function createV3Client(config) { + const createNativeOrder = async ({ outTradeNo, description, amountCents, clientIp }) => { + const data = await wechatV3Request(config, "POST", "/v3/pay/transactions/native", { + appid: config.appId, + mchid: config.mchId, + description, + out_trade_no: outTradeNo, + notify_url: config.notifyUrl, + amount: { total: amountCents, currency: "CNY" }, + scene_info: clientIp ? { payer_client_ip: clientIp } : void 0 + }); + return { codeUrl: data.code_url }; + }; + const createH5Order = async ({ outTradeNo, description, amountCents, clientIp }) => { + const data = await wechatV3Request(config, "POST", "/v3/pay/transactions/h5", { + appid: config.appId, + mchid: config.mchId, + description, + out_trade_no: outTradeNo, + notify_url: config.notifyUrl, + amount: { total: amountCents, currency: "CNY" }, + scene_info: { + payer_client_ip: clientIp || "127.0.0.1", + h5_info: { + type: "Wap", + app_name: "TKMind", + app_url: process.env.H5_PUBLIC_BASE_URL ?? "https://go.tkmind.cn" + } + } + }); + return { h5Url: data.h5_url }; + }; + const createJsapiOrder = async ({ outTradeNo, description, amountCents, clientIp, openid }) => { + if (!openid) { + throw new Error("JSAPI \u652F\u4ED8\u7F3A\u5C11 openid"); + } + const data = await wechatV3Request(config, "POST", "/v3/pay/transactions/jsapi", { + appid: config.appId, + mchid: config.mchId, + description, + out_trade_no: outTradeNo, + notify_url: config.notifyUrl, + amount: { total: amountCents, currency: "CNY" }, + payer: { openid }, + scene_info: clientIp ? { payer_client_ip: clientIp } : void 0 + }); + if (!data.prepay_id) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u8FD4\u56DE prepay_id"); + } + return { jsapiParams: buildV3JsapiParams(config, data.prepay_id) }; + }; + const verifyNotify = ({ headers, body }) => { + const timestamp = headers["wechatpay-timestamp"] ?? headers["Wechatpay-Timestamp"]; + const nonce = headers["wechatpay-nonce"] ?? headers["Wechatpay-Nonce"]; + const signature = headers["wechatpay-signature"] ?? headers["Wechatpay-Signature"]; + const serial = headers["wechatpay-serial"] ?? headers["Wechatpay-Serial"]; + if (!timestamp || !nonce || !signature) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u7F3A\u5C11\u7B7E\u540D\u5934"); + } + const bodyText = typeof body === "string" ? body : JSON.stringify(body); + const message = `${timestamp} +${nonce} +${bodyText} +`; + if (!config.skipNotifyVerify) { + if (!config.platformCert) { + throw new Error("\u672A\u914D\u7F6E\u5FAE\u4FE1\u652F\u4ED8\u5E73\u53F0\u8BC1\u4E66\uFF0C\u65E0\u6CD5\u9A8C\u7B7E"); + } + const verified = crypto24.createVerify("RSA-SHA256").update(message).verify(config.platformCert, signature, "base64"); + if (!verified) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u9A8C\u7B7E\u5931\u8D25"); + } + } + const payload = typeof body === "string" ? JSON.parse(body) : body; + const resource = payload?.resource; + if (!resource?.ciphertext || !resource?.nonce) { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u6570\u636E\u4E0D\u5B8C\u6574"); + } + const cipherBuf = Buffer.from(resource.ciphertext, "base64"); + const authTag = cipherBuf.subarray(cipherBuf.length - 16); + const dataBuf = cipherBuf.subarray(0, cipherBuf.length - 16); + const decipher = crypto24.createDecipheriv( + "aes-256-gcm", + Buffer.from(config.apiV3Key, "utf8"), + Buffer.from(resource.nonce, "utf8") + ); + decipher.setAuthTag(authTag); + if (resource.associated_data) { + decipher.setAAD(Buffer.from(resource.associated_data, "utf8")); + } + const plain = Buffer.concat([decipher.update(dataBuf), decipher.final()]).toString("utf8"); + const transaction = JSON.parse(plain); + return { serial, transaction }; + }; + return { + enabled: true, + apiVersion: "v3", + appId: config.appId, + createNativeOrder, + createH5Order, + createJsapiOrder, + verifyNotify + }; +} +function createWechatPayClient(config) { + if (!config?.enabled) { + return { + enabled: false, + apiVersion: config?.apiVersion ?? null, + appId: config?.appId ?? null, + async createNativeOrder() { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u914D\u7F6E"); + }, + async createH5Order() { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u914D\u7F6E"); + }, + async createJsapiOrder() { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u914D\u7F6E"); + }, + verifyNotify() { + throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u672A\u914D\u7F6E"); + } + }; + } + if (config.apiVersion === "v2") { + return createV2Client(config); + } + return createV3Client(config); +} +var WECHAT_NOTIFY_SUCCESS_V2 = ""; + +// wechat-oauth.mjs +import crypto25 from "node:crypto"; +import { fetch as fetch3 } from "undici"; +var OAUTH_AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize"; +var OAUTH_QRCONNECT_URL = "https://open.weixin.qq.com/connect/qrconnect"; +var OAUTH_ACCESS_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token"; +var OAUTH_USERINFO_URL = "https://api.weixin.qq.com/sns/userinfo"; +var STATE_TTL_MS = 10 * 60 * 1e3; +var VALID_INTENTS = /* @__PURE__ */ new Set(["login", "register", "bind"]); +var VALID_AUTH_MODES = /* @__PURE__ */ new Set(["mp", "open", "scan"]); +function isPollScanAuthMode(authMode) { + return authMode === "open" || authMode === "scan"; +} +function resolveCallbackUrl(env = process.env) { + const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? ""; + if (explicit) return explicit; + const base = env.H5_PUBLIC_BASE_URL?.trim() ?? ""; + if (base) return `${base.replace(/\/$/, "")}/auth/wechat/callback`; + return ""; +} +function isWechatUserAgent(userAgent = "") { + const ua = userAgent || ""; + return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua); +} +function loadWechatOAuthConfig(env = process.env) { + const enabledFlag = env.H5_WECHAT_OAUTH_ENABLED === "1"; + const appId = env.H5_WECHAT_APP_ID?.trim() ?? env.H5_WECHAT_OAUTH_APP_ID?.trim() ?? ""; + const appSecret = env.H5_WECHAT_APP_SECRET?.trim() ?? env.H5_WECHAT_OAUTH_APP_SECRET?.trim() ?? ""; + const openAppId = env.H5_WECHAT_OPEN_APP_ID?.trim() ?? ""; + const openAppSecret = env.H5_WECHAT_OPEN_APP_SECRET?.trim() ?? ""; + const scope = env.H5_WECHAT_OAUTH_SCOPE?.trim() || "snsapi_userinfo"; + const callbackUrl = resolveCallbackUrl(env); + const configured = Boolean(appId && appSecret && callbackUrl); + const scanConfigured = Boolean(openAppId && openAppSecret && callbackUrl); + return { + enabled: enabledFlag && configured, + appId, + appSecret, + openAppId, + openAppSecret, + scanEnabled: enabledFlag && scanConfigured, + scope, + callbackUrl + }; +} +function sanitizeWechatReturnTo(returnTo, req) { + if (!returnTo || typeof returnTo !== "string") return "/"; + const host = req.get("host"); + try { + const url = new URL(returnTo, `http://${host}`); + if (url.host !== host) return "/"; + if (!url.pathname.startsWith("/")) return "/"; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + if (returnTo.startsWith("/") && !returnTo.startsWith("//")) return returnTo; + return "/"; + } +} +function normalizeIntent(intent) { + const value = typeof intent === "string" ? intent.trim().toLowerCase() : "login"; + return VALID_INTENTS.has(value) ? value : "login"; +} +function normalizeAuthMode(authMode) { + const value = typeof authMode === "string" ? authMode.trim().toLowerCase() : "mp"; + return VALID_AUTH_MODES.has(value) ? value : "mp"; +} +function buildAuthorizeUrl(config, { state, authMode = "mp" }) { + const isOpen = authMode === "open"; + const useMp = authMode === "mp" || authMode === "scan"; + const params = new URLSearchParams({ + appid: isOpen ? config.openAppId : config.appId, + redirect_uri: config.callbackUrl, + response_type: "code", + scope: isOpen ? "snsapi_login" : config.scope, + state + }); + const base = isOpen ? OAUTH_QRCONNECT_URL : OAUTH_AUTHORIZE_URL; + return `${base}?${params.toString()}#wechat_redirect`; +} +async function exchangeCode(config, code, authMode = "mp") { + const isOpen = authMode === "open"; + const params = new URLSearchParams({ + appid: isOpen ? config.openAppId : config.appId, + secret: isOpen ? config.openAppSecret : config.appSecret, + code, + grant_type: "authorization_code" + }); + const res = await fetch3(`${OAUTH_ACCESS_TOKEN_URL}?${params.toString()}`); + const data = await res.json(); + if (data.errcode) { + throw new Error(data.errmsg || `\u5FAE\u4FE1\u6388\u6743\u5931\u8D25 (${data.errcode})`); + } + if (!data.openid) { + throw new Error("\u5FAE\u4FE1\u6388\u6743\u672A\u8FD4\u56DE openid"); + } + return { + accessToken: data.access_token, + openid: data.openid, + unionid: data.unionid ?? null, + refreshToken: data.refresh_token ?? null, + appId: isOpen ? config.openAppId : config.appId + }; +} +async function fetchUserInfo(accessToken, openid) { + const params = new URLSearchParams({ + access_token: accessToken, + openid, + lang: "zh_CN" + }); + const res = await fetch3(`${OAUTH_USERINFO_URL}?${params.toString()}`); + const data = await res.json(); + if (data.errcode) { + throw new Error(data.errmsg || `\u83B7\u53D6\u5FAE\u4FE1\u7528\u6237\u4FE1\u606F\u5931\u8D25 (${data.errcode})`); + } + return { + nickname: typeof data.nickname === "string" ? data.nickname.slice(0, 128) : null, + avatarUrl: typeof data.headimgurl === "string" ? data.headimgurl.slice(0, 512) : null, + unionid: data.unionid ?? null + }; +} +function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { + if (!config?.enabled) { + return { + enabled: false, + publicConfig() { + return { enabled: false, inWechat: false, scanEnabled: false }; + } + }; + } + const pruneExpiredStates = async (now = Date.now()) => { + if (!pool) return; + await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE expires_at <= ?`, [now]); + }; + const createState = async ({ + returnTo = "/", + utmSource = null, + utmMedium = null, + utmCampaign = null, + intent = "login", + bindUserId = null, + authMode = "mp", + now = Date.now() + } = {}) => { + const state = crypto25.randomBytes(24).toString("base64url"); + if (pool) { + await pruneExpiredStates(now); + await pool.query( + `INSERT INTO h5_wechat_oauth_states + (state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id, + auth_mode, status, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, + [ + state, + returnTo, + utmSource, + utmMedium, + utmCampaign, + normalizeIntent(intent), + bindUserId, + normalizeAuthMode(authMode), + now + STATE_TTL_MS, + now + ] + ); + } + return state; + }; + const loadState = async (state, now = Date.now()) => { + if (!state || !pool) return null; + const [rows] = await pool.query( + `SELECT state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id, + auth_mode, status, result_kind, result_token, result_message, expires_at + FROM h5_wechat_oauth_states + WHERE state = ? + LIMIT 1`, + [state] + ); + const row = rows[0]; + if (!row || Number(row.expires_at) <= now) return null; + return row; + }; + const consumeState = async (state, now = Date.now()) => { + const row = await loadState(state, now); + if (!row) return null; + await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); + return { + returnTo: row.return_to || "/", + utmSource: row.utm_source, + utmMedium: row.utm_medium, + utmCampaign: row.utm_campaign, + intent: row.intent || "login", + bindUserId: row.bind_user_id, + authMode: row.auth_mode || "mp" + }; + }; + const markStateComplete = async (state, { resultKind, resultToken = null, resultMessage = null }, now = Date.now()) => { + await pool.query( + `UPDATE h5_wechat_oauth_states + SET status = 'done', result_kind = ?, result_token = ?, result_message = ?, expires_at = ? + WHERE state = ?`, + [resultKind, resultToken, resultMessage, now + STATE_TTL_MS, state] + ); + }; + const buildAuthorizeRedirect = async (req, { bindUserId = null } = {}) => { + const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req); + const intent = normalizeIntent(req.query?.intent); + const authMode = normalizeAuthMode(req.query?.auth_mode); + if (authMode === "open" && !config.scanEnabled) { + throw new Error("\u5FAE\u4FE1\u626B\u7801\u767B\u5F55\u672A\u914D\u7F6E"); + } + const state = await createState({ + returnTo, + utmSource: typeof req.query?.utm_source === "string" ? req.query.utm_source : null, + utmMedium: typeof req.query?.utm_medium === "string" ? req.query.utm_medium : null, + utmCampaign: typeof req.query?.utm_campaign === "string" ? req.query.utm_campaign : null, + intent, + bindUserId, + authMode + }); + return buildAuthorizeUrl(config, { state, authMode }); + }; + const startScanLogin = async (req) => { + const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req); + const utmSource = typeof req.query?.utm_source === "string" ? req.query.utm_source : null; + const utmMedium = typeof req.query?.utm_medium === "string" ? req.query.utm_medium : null; + const utmCampaign = typeof req.query?.utm_campaign === "string" ? req.query.utm_campaign : null; + const common = { + returnTo, + utmSource, + utmMedium, + utmCampaign, + intent: "login" + }; + if (config.scanEnabled) { + const state2 = await createState({ ...common, authMode: "open" }); + return { + mode: "open", + state: state2, + openAppId: config.openAppId, + redirectUri: config.callbackUrl, + expiresInMs: STATE_TTL_MS + }; + } + const state = await createState({ ...common, authMode: "scan" }); + return { + mode: "mp", + state, + qrUrl: buildAuthorizeUrl(config, { state, authMode: "scan" }), + expiresInMs: STATE_TTL_MS + }; + }; + const pollScanLogin = async (state, now = Date.now()) => { + const row = await loadState(state, now); + if (!row) return { status: "expired" }; + if (row.status !== "done") return { status: "pending" }; + await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); + if (row.result_kind === "error") { + return { status: "error", message: row.result_message || "\u5FAE\u4FE1\u767B\u5F55\u5931\u8D25" }; + } + if (row.result_kind === "binding_gate") { + return { status: "binding_gate", pendingToken: row.result_token }; + } + if (row.result_kind === "login") { + return { status: "complete", token: row.result_token }; + } + return { status: "error", message: "\u5FAE\u4FE1\u767B\u5F55\u7ED3\u679C\u65E0\u6548" }; + }; + const handleCallback = async ({ code, state, ip = "unknown" }) => { + if (!code) { + throw new Error("\u7F3A\u5C11\u5FAE\u4FE1\u6388\u6743 code"); + } + const stateRow = await loadState(state); + if (!stateRow) { + throw new Error("\u6388\u6743\u72B6\u6001\u65E0\u6548\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + } + const authMode = stateRow.auth_mode || "mp"; + const tokenInfo = await exchangeCode(config, code, authMode); + let nickname = null; + let avatarUrl = null; + let unionid = tokenInfo.unionid; + if ((authMode === "mp" || authMode === "scan") && config.scope === "snsapi_userinfo") { + const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid); + nickname = profile.nickname; + avatarUrl = profile.avatarUrl; + unionid = profile.unionid ?? unionid; + } + if (authMode === "open") { + try { + const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid); + nickname = profile.nickname; + avatarUrl = profile.avatarUrl; + unionid = profile.unionid ?? unionid; + } catch { + } + } + if (!userAuth2?.resolveWechatAuth) { + throw new Error("\u7528\u6237\u7CFB\u7EDF\u672A\u5C31\u7EEA"); + } + const result = await userAuth2.resolveWechatAuth({ + appId: tokenInfo.appId, + openid: tokenInfo.openid, + unionid, + nickname, + avatarUrl, + intent: stateRow.intent || "login", + bindUserId: stateRow.bind_user_id, + returnTo: stateRow.return_to || "/", + utmSource: stateRow.utm_source, + utmMedium: stateRow.utm_medium, + utmCampaign: stateRow.utm_campaign, + ip + }); + if (!result.ok) { + if (isPollScanAuthMode(authMode)) { + await markStateComplete(state, { + resultKind: "error", + resultMessage: result.message || "\u5FAE\u4FE1\u767B\u5F55\u5931\u8D25" + }); + return { + authMode, + action: "poll_error", + message: result.message || "\u5FAE\u4FE1\u767B\u5F55\u5931\u8D25" + }; + } + throw new Error(result.message || "\u5FAE\u4FE1\u767B\u5F55\u5931\u8D25"); + } + if (result.action === "binding_gate") { + if (isPollScanAuthMode(authMode)) { + await markStateComplete(state, { + resultKind: "binding_gate", + resultToken: result.pendingToken + }); + return { + authMode, + action: "binding_gate", + pendingToken: result.pendingToken, + wechatProfile: result.wechatProfile, + returnTo: result.returnTo + }; + } + return { + authMode, + action: "binding_gate", + pendingToken: result.pendingToken, + wechatProfile: result.wechatProfile, + returnTo: result.returnTo, + utmSource: result.utmSource, + utmMedium: result.utmMedium, + utmCampaign: result.utmCampaign + }; + } + if (isPollScanAuthMode(authMode)) { + await markStateComplete(state, { + resultKind: "login", + resultToken: result.token + }); + return { + authMode, + action: "login", + token: result.token, + user: result.user, + isNewUser: result.isNewUser, + returnTo: stateRow.return_to || "/", + utmSource: stateRow.utm_source, + utmMedium: stateRow.utm_medium, + utmCampaign: stateRow.utm_campaign + }; + } + await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); + return { + authMode, + action: "login", + token: result.token, + user: result.user, + isNewUser: result.isNewUser, + bound: result.bound, + returnTo: stateRow.return_to || "/", + utmSource: stateRow.utm_source, + utmMedium: stateRow.utm_medium, + utmCampaign: stateRow.utm_campaign + }; + }; + return { + enabled: true, + publicConfig(req) { + const inWechat = req ? isWechatUserAgent(req.get?.("user-agent") || "") : false; + return { + enabled: true, + inWechat, + scanEnabled: true, + openScanEnabled: Boolean(config.scanEnabled), + appId: config.appId, + openAppId: config.scanEnabled ? config.openAppId : void 0, + oauthCallbackUrl: config.scanEnabled ? config.callbackUrl : void 0 + }; + }, + buildAuthorizeRedirect, + startScanLogin, + pollScanLogin, + handleCallback + }; +} + +// wechat-mp.mjs +import crypto27 from "node:crypto"; +import fs22 from "node:fs"; +import path21 from "node:path"; +import { fetch as undiciFetch7 } from "undici"; + +// schedule-intent.mjs +function normalizeText2(text) { + return String(text ?? "").replace(/\s+/g, "").trim(); +} +function chineseHourToNumber(value) { + const raw = String(value ?? "").trim(); + if (/^\d{1,2}$/.test(raw)) return Number(raw); + const map = { + \u96F6: 0, + \u4E00: 1, + \u4E8C: 2, + \u4E24: 2, + \u4E09: 3, + \u56DB: 4, + \u4E94: 5, + \u516D: 6, + \u4E03: 7, + \u516B: 8, + \u4E5D: 9, + \u5341: 10 + }; + if (raw === "\u5341") return 10; + if (raw.startsWith("\u5341")) return 10 + (map[raw.slice(1)] ?? 0); + if (raw.endsWith("\u5341")) return (map[raw[0]] ?? 0) * 10; + if (raw.includes("\u5341")) { + const [tens, ones] = raw.split("\u5341"); + return (map[tens] ?? 1) * 10 + (map[ones] ?? 0); + } + return map[raw] ?? null; +} +function parseHourMinute(text) { + const match = text.match(/(?:早上|上午|清晨|每天早上|每天上午)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(半|[0-9]{1,2}分?)?/); + if (!match) return null; + const hour = chineseHourToNumber(match[1]); + if (hour === null || hour < 0 || hour > 23) return null; + let minute = 0; + if (match[2] === "\u534A") minute = 30; + else if (match[2]) minute = Number(String(match[2]).replace("\u5206", "")); + if (!Number.isFinite(minute) || minute < 0 || minute > 59) return null; + return { hour, minute }; +} +function extractTodoTitle(text) { + const original = String(text ?? "").trim(); + if (!original) return null; + const quoted = original.match(/[「『【((“"']([^」』】))”"']{1,80})[」』】))”"']/); + if (quoted?.[1]) { + const title = quoted[1].trim(); + if (title) return title; + } + const trailingCommand = original.match( + /^(.{1,80}?)(?:帮我)?(?:设|设置|设下|记)(?:一个|下一个)?(?:待办|代办|带办|代拜)(?:吧|一下)?$/u + ); + if (trailingCommand?.[1]) { + const title = trailingCommand[1].trim(); + if (title) return title; + } + const cleaned = original.replace( + /^.*?(?:不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)(?:[::,,、\s]+)?/u, + "" + ).trim(); + return cleaned || null; +} +function parseScheduleIntent(text) { + const compact = normalizeText2(text); + if (!compact) return { action: "none" }; + const wantsDaily = /每天|每日|天天/.test(compact); + const wantsTodoDigest = /(待办|todo|任务).*(记录|列表|清单|安排|摘要)|一天的待办/.test(compact); + const wantsSend = /发|发送|推送|提醒|通知|给我/.test(compact); + if (wantsDaily && wantsTodoDigest && wantsSend) { + const time = parseHourMinute(compact); + if (!time) { + return { + action: "create_daily_todo_digest", + needsClarification: ["digest_time"] + }; + } + return { + action: "create_daily_todo_digest", + hour: time.hour, + minute: time.minute + }; + } + const wantsBalanceAlert = /(余额|钱包|账户).*(不足|低于|提醒|预警|通知)|余额不足|低余额|余额预警/.test(compact); + if (wantsBalanceAlert) { + const thresholdMatch = compact.match(/(?:低于|少于|不足|小于)(\d{1,6})(?:元|块|rmb|人民币|cny)?/i); + const thresholdYuan = thresholdMatch ? Number(thresholdMatch[1]) : null; + const thresholdCents = thresholdYuan == null ? null : Math.max(0, Math.round(thresholdYuan * 100)); + return thresholdCents == null ? { action: "create_balance_alert", needsClarification: ["threshold"] } : { action: "create_balance_alert", thresholdCents }; + } + if (/看看|查看|看下|列出/.test(compact) && /(待办|日程|行程|计划|任务)/.test(compact)) { + return { action: "query_schedule" }; + } + const wantsTodoRecord = /(不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)/.test( + compact + ); + if (wantsTodoRecord) { + const explicitNoReminder = /(不用提醒|不提醒|先记一下)/.test(compact); + const hasScheduleTime = /(提醒|闹钟|叫我|今天|今晚|明天|后天|早上|上午|中午|下午|晚上|[0-9零一二两三四五六七八九十]{1,3}(点|:|:))/.test( + compact + ); + if (!explicitNoReminder && hasScheduleTime) { + return { action: "schedule_agent" }; + } + const title = extractTodoTitle(text); + if (!title) { + return { action: "create_todo", needsClarification: ["todo_title"] }; + } + return { action: "create_todo", title }; + } + return { action: "none" }; +} +function isScheduleIntent(intent) { + return intent?.action && intent.action !== "none"; +} +function shouldUseScheduleAssistant(text) { + const compact = normalizeText2(text); + if (!compact) return false; + if (isScheduleIntent(parseScheduleIntent(text))) return true; + const scheduleKeywords = /(提醒|待办|代办|带办|代拜|日程|行程|安排|计划|闹钟)/; + const timeKeywords = /(今天|今晚|明天|后天|早上|上午|中午|下午|晚上|\d{1,2}[点::])/; + return scheduleKeywords.test(compact) && timeKeywords.test(compact); +} + +// wechat-media.mjs +import crypto26 from "node:crypto"; +import fs21 from "node:fs"; +import path20 from "node:path"; +import { fileURLToPath as fileURLToPath5 } from "node:url"; +import { fetch as undiciFetch6 } from "undici"; +var __dirname4 = path20.dirname(fileURLToPath5(import.meta.url)); +var DEFAULT_WECHAT_MEDIA_URL = "https://api.weixin.qq.com/cgi-bin/media/get"; +var DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +var ALLOWED_IMAGE_MIME_TYPES = /* @__PURE__ */ new Map([ + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], + ["image/gif", "gif"] +]); +function resolveImageExtension(contentType = "", fallbackUrl = "") { + const normalized = String(contentType ?? "").split(";")[0].trim().toLowerCase(); + if (ALLOWED_IMAGE_MIME_TYPES.has(normalized)) { + return { + mimeType: normalized, + extension: ALLOWED_IMAGE_MIME_TYPES.get(normalized) + }; + } + const ext = path20.extname(String(fallbackUrl ?? "").split("?")[0]).replace(/^\./, "").toLowerCase(); + if (ext === "jpg" || ext === "jpeg") return { mimeType: "image/jpeg", extension: "jpg" }; + if (ext === "png") return { mimeType: "image/png", extension: "png" }; + if (ext === "webp") return { mimeType: "image/webp", extension: "webp" }; + if (ext === "gif") return { mimeType: "image/gif", extension: "gif" }; + return null; +} +function ensureImageWithinLimit(buffer, maxBytes) { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + throw new Error("\u5FAE\u4FE1\u56FE\u7247\u5185\u5BB9\u4E3A\u7A7A"); + } + if (buffer.length > maxBytes) { + throw new Error(`\u56FE\u7247\u8D85\u8FC7\u5927\u5C0F\u9650\u5236\uFF08${maxBytes} bytes\uFF09`); + } +} +async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch = undiciFetch6 } = {}) { + if (!accessToken) throw new Error("\u7F3A\u5C11\u5FAE\u4FE1 access_token"); + if (!mediaId) throw new Error("\u7F3A\u5C11\u5FAE\u4FE1 mediaId"); + const url = new URL(DEFAULT_WECHAT_MEDIA_URL); + url.searchParams.set("access_token", accessToken); + url.searchParams.set("media_id", mediaId); + const response = await wechatFetch(url.toString(), { + method: "GET", + headers: { Accept: "*/*" } + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(text || `\u5FAE\u4FE1\u4E34\u65F6\u7D20\u6750\u4E0B\u8F7D\u5931\u8D25 (${response.status})`); + } + const contentType = response.headers.get("content-type") || ""; + const buffer = Buffer.from(await response.arrayBuffer()); + return { buffer, contentType }; +} +function buildWechatImagePublicUrl({ + publicBaseUrl, + publishKey, + filename, + publicBasePath = `${PUBLIC_ZONE_DIR}/wechat-mp` +}) { + if (!publicBaseUrl) throw new Error("\u7F3A\u5C11\u56FE\u7247\u516C\u7F51\u57FA\u7840\u5730\u5740"); + if (!publishKey) throw new Error("\u7F3A\u5C11\u7528\u6237 publish key"); + if (!filename) throw new Error("\u7F3A\u5C11\u56FE\u7247\u6587\u4EF6\u540D"); + return buildPublicUrl(publicBaseUrl, publishKey, `${publicBasePath}/${filename}`); +} +async function persistWechatImage({ + userId, + appId, + openid, + msgId, + mediaId, + picUrl, + publicBaseUrl, + maxImageBytes = DEFAULT_MAX_IMAGE_BYTES +}, { + wechatFetch = undiciFetch6, + accessToken, + h5Root = __dirname4 +} = {}) { + if (!userId) throw new Error("\u7F3A\u5C11 userId"); + const publishDir = path20.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, "wechat-mp"); + fs21.mkdirSync(publishDir, { recursive: true }); + let source = "wechat_media"; + let buffer; + let contentType = ""; + if (mediaId && accessToken) { + try { + const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch }); + buffer = downloaded.buffer; + contentType = downloaded.contentType; + } catch (error) { + if (!picUrl) throw error; + } + } + if (!buffer && picUrl) { + source = "wechat_pic_url"; + const response = await wechatFetch(picUrl, { + method: "GET", + headers: { Accept: "image/*,*/*;q=0.8" } + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(text || `\u5FAE\u4FE1\u56FE\u7247\u4E0B\u8F7D\u5931\u8D25 (${response.status})`); + } + contentType = response.headers.get("content-type") || contentType; + buffer = Buffer.from(await response.arrayBuffer()); + } + ensureImageWithinLimit(buffer, maxImageBytes); + const resolved = resolveImageExtension(contentType, picUrl); + if (!resolved) { + throw new Error(`\u6682\u4E0D\u652F\u6301\u7684\u56FE\u7247\u7C7B\u578B\uFF1A${contentType || "unknown"}`); + } + const timestamp = Date.now(); + const hash = crypto26.createHash("sha1").update(buffer).digest("hex").slice(0, 12); + const fileBase = [appId || "wx", openid || "openid", msgId || timestamp, mediaId || hash].filter(Boolean).join("-").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120); + const filename = `${fileBase}.${resolved.extension}`; + const absolutePath = path20.join(publishDir, filename); + fs21.writeFileSync(absolutePath, buffer); + return { + absolutePath, + bytes: buffer.length, + contentType: resolved.mimeType, + filename, + publicUrl: buildWechatImagePublicUrl({ + publicBaseUrl, + publishKey: String(userId), + filename + }), + source + }; +} + +// wechat/ack/ack-intent.mjs +var INTENT_RULES = [ + { task: "translate", pattern: /翻译|译成|translate/i }, + { task: "summary", pattern: /总结|概括|摘要|提炼|归纳/ }, + { task: "rewrite", pattern: /改写|润色|优化文|帮我写|写一[篇封份个]/ }, + { task: "poster", pattern: /海报|宣传图|banner/i }, + { task: "ppt", pattern: /ppt|PPT|幻灯片/ }, + { task: "mindmap", pattern: /思维导图|脑图/ }, + { task: "code", pattern: /代码|写个函数|写个方法|debug|报错|bug/i }, + { task: "search", pattern: /查一下|搜索|帮我找|查找/ } +]; +function resolveIntent(msgType, text) { + if (msgType === "image") return { task: "image_analysis" }; + if (msgType === "voice") return { task: "voice_analysis" }; + if (msgType === "location") return { task: "location" }; + if (msgType === "link") return { task: "link" }; + const t = String(text ?? "").trim(); + if (!t) return { task: "unknown" }; + if (shouldUseScheduleAssistant(t)) return { task: "schedule" }; + for (const rule of INTENT_RULES) { + if (rule.pattern.test(t)) return { task: rule.task }; + } + return { task: "unknown" }; +} + +// wechat/ack/ack-templates.mjs +var TEMPLATES = { + default: [ + "\u6536\u5230\uFF0C\u6211\u9A6C\u4E0A\u5904\u7406\u3002", + "\u6536\u5230\uFF0C\u6211\u8FD9\u5C31\u5F00\u59CB\u3002", + "\u597D\u7684\uFF0C\u6211\u5148\u5904\u7406\u4E00\u4E0B\u3002", + "\u660E\u767D\uFF0C\u6211\u5F00\u59CB\u4E86\u3002", + "\u6536\u5230\uFF0C\u5F88\u5FEB\u7ED9\u4F60\u7ED3\u679C\u3002" + ], + image: [ + "\u56FE\u7247\u6536\u5230\uFF0C\u6211\u5148\u770B\u770B\u3002", + "\u6536\u5230\u56FE\u7247\uFF0C\u6211\u5206\u6790\u4E00\u4E0B\u3002", + "\u8BA9\u6211\u770B\u770B\u8FD9\u5F20\u56FE\u7247\u3002", + "\u56FE\u7247\u5DF2\u6536\u5230\uFF0C\u6211\u6765\u5904\u7406\u3002" + ], + voice: [ + "\u6536\u5230\u8BED\u97F3\uFF0C\u6211\u5148\u542C\u4E00\u4E0B\u3002", + "\u542C\u5230\u5566\uFF0C\u6211\u9A6C\u4E0A\u5904\u7406\u3002", + "\u8BED\u97F3\u6536\u5230\uFF0C\u6211\u5148\u8F6C\u6587\u5B57\u3002" + ], + location: ["\u6536\u5230\u4F4D\u7F6E\uFF0C\u6211\u6765\u770B\u770B\u3002"], + link: ["\u6536\u5230\u94FE\u63A5\uFF0C\u6211\u53BB\u8BFB\u4E00\u4E0B\u3002"], + intent: { + translate: ["\u6536\u5230\uFF0C\u6211\u5F00\u59CB\u7FFB\u8BD1\u3002", "\u597D\u7684\uFF0C\u6211\u6765\u7FFB\u8BD1\u4E00\u4E0B\u3002"], + summary: ["\u6536\u5230\uFF0C\u6211\u5F00\u59CB\u6574\u7406\u91CD\u70B9\u3002", "\u597D\u7684\uFF0C\u6211\u6765\u63D0\u70BC\u4E00\u4E0B\u3002"], + rewrite: ["\u6536\u5230\uFF0C\u6211\u5F00\u59CB\u5199\u3002", "\u597D\u7684\uFF0C\u6211\u6765\u6539\u4E00\u4E0B\u3002"], + poster: ["\u6536\u5230\uFF0C\u6211\u6765\u8BBE\u8BA1\u6D77\u62A5\u3002"], + ppt: ["\u6536\u5230\uFF0C\u6211\u5F00\u59CB\u5236\u4F5C PPT\u3002"], + mindmap: ["\u6536\u5230\uFF0C\u6211\u6765\u6574\u7406\u601D\u7EF4\u5BFC\u56FE\u3002"], + code: ["\u6536\u5230\uFF0C\u6211\u6765\u770B\u770B\u4EE3\u7801\u3002", "\u597D\u7684\uFF0C\u6211\u5F00\u59CB\u5904\u7406\u3002"], + search: ["\u6536\u5230\uFF0C\u6211\u53BB\u67E5\u4E00\u4E0B\u3002", "\u597D\u7684\uFF0C\u6211\u6765\u641C\u7D22\u4E00\u4E0B\u3002"], + schedule: ["\u6536\u5230\uFF0C\u6211\u6765\u5E2E\u4F60\u8BBE\u7F6E\u3002"] + } +}; + +// wechat/ack/ack-builders.mjs +function pickRandom(arr, randomEnabled) { + if (!randomEnabled || arr.length === 1) return arr[0]; + return arr[Math.floor(Math.random() * arr.length)]; +} +function withNickname(text, nickname, nicknameEnabled) { + if (!nicknameEnabled || !nickname) return text; + return `${nickname}\uFF0C${text}`; +} +function buildText(pool, ctx) { + const text = pickRandom(pool, ctx.randomEnabled); + return withNickname(text, ctx.nickname, ctx.nicknameEnabled); +} +var ImageBuilder = { + priority: 80, + support: (ctx) => ctx.msgType === "image", + build: (ctx) => buildText(TEMPLATES.image, ctx) +}; +var VoiceBuilder = { + priority: 80, + support: (ctx) => ctx.msgType === "voice", + build: (ctx) => buildText(TEMPLATES.voice, ctx) +}; +var LocationBuilder = { + priority: 80, + support: (ctx) => ctx.msgType === "location", + build: (ctx) => buildText(TEMPLATES.location, ctx) +}; +var LinkBuilder = { + priority: 80, + support: (ctx) => ctx.msgType === "link", + build: (ctx) => buildText(TEMPLATES.link, ctx) +}; +var IntentBuilder = { + priority: 50, + support: (ctx) => { + const task = ctx.intent?.task; + return task && task !== "unknown" && TEMPLATES.intent[task]; + }, + build: (ctx) => buildText(TEMPLATES.intent[ctx.intent.task], ctx) +}; +var DefaultBuilder = { + priority: 0, + support: () => true, + build: (ctx) => buildText(TEMPLATES.default, ctx) +}; +var BUILDERS = [ImageBuilder, VoiceBuilder, LocationBuilder, LinkBuilder, IntentBuilder, DefaultBuilder].sort((a, b) => b.priority - a.priority); +function selectBuilder(ctx) { + return BUILDERS.find((b) => b.support(ctx)) ?? DefaultBuilder; +} + +// wechat/ack/ack-provider.mjs +function readConfig(config) { + return { + randomEnabled: config?.ackRandomEnabled !== false, + nicknameEnabled: config?.ackNicknameEnabled !== false + }; +} +function buildAckText({ intent, nickname, config, fallbackText }) { + try { + const cfg = readConfig(config); + const ctx = { + msgType: String(intent?.msgType ?? "text"), + nickname: String(nickname ?? ""), + intent: resolveIntent(intent?.msgType, intent?.agentText), + randomEnabled: cfg.randomEnabled, + nicknameEnabled: cfg.nicknameEnabled + }; + return selectBuilder(ctx).build(ctx); + } catch { + return fallbackText ?? "\u5DF2\u6536\u5230\uFF0C\u6B63\u5728\u5904\u7406\uFF0C\u5B8C\u6210\u540E\u53D1\u5230\u8FD9\u91CC\u3002"; + } +} + +// wechat-mp.mjs +var DEFAULT_WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/stable_token"; +var DEFAULT_WECHAT_CUSTOMER_SERVICE_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send"; +var DEFAULT_WECHAT_JSAPI_TICKET_URL = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"; +var DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? "https://asr.tkmind.cn"; +var DEFAULT_ACK_TEXT = "\u5DF2\u6536\u5230\uFF0C\u6B63\u5728\u5904\u7406\uFF0C\u5B8C\u6210\u540E\u53D1\u5230\u8FD9\u91CC\u3002"; +var DEFAULT_PROGRESS_TEXT = "\u8FD8\u5728\u5904\u7406\uFF0C\u8BF7\u7A0D\u7B49\u7247\u523B\u3002"; +var DEFAULT_STATUS_TEXT = "\u6211\u5728\u8FD9\u8FB9\u3002\u4E0A\u4E00\u6761\u5982\u679C\u8FD8\u6CA1\u5B8C\u6210\uFF0C\u6211\u4F1A\u7EE7\u7EED\u628A\u7ED3\u679C\u53D1\u7ED9\u4F60\uFF1B\u4F60\u4E5F\u53EF\u4EE5\u76F4\u63A5\u8865\u4E00\u53E5\u8981\u6C42\u3002"; +var DEFAULT_UNSUPPORTED_TEXT = "\u5F53\u524D\u5148\u652F\u6301\u6587\u5B57\u6D88\u606F\uFF0C\u4F60\u53EF\u4EE5\u76F4\u63A5\u53D1\u6587\u5B57\u7ED9\u6211\u3002"; +var DEFAULT_UNBOUND_TEXT = "\u5148\u70B9\u8FD9\u91CC\u5B8C\u6210\u7ED1\u5B9A\uFF0C\u518D\u7EE7\u7EED\u548C\u4E13\u5C5E Agent \u5BF9\u8BDD\uFF1A"; +var DEFAULT_PROGRESS_DELAY_MS = 8e3; +var PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; +function parseXmlField(xml, field) { + const cdataMatch = xml.match(new RegExp(`<${field}><\\/${field}>`)); + if (cdataMatch) return cdataMatch[1]; + const plainMatch = xml.match(new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`)); + return plainMatch ? plainMatch[1].trim() : ""; +} +function parseWechatMessage(xml) { + return { + toUserName: parseXmlField(xml, "ToUserName"), + fromUserName: parseXmlField(xml, "FromUserName"), + createTime: Number(parseXmlField(xml, "CreateTime") || 0), + msgType: parseXmlField(xml, "MsgType").toLowerCase(), + content: parseXmlField(xml, "Content"), + msgId: parseXmlField(xml, "MsgId"), + mediaId: parseXmlField(xml, "MediaId"), + format: parseXmlField(xml, "Format"), + recognition: parseXmlField(xml, "Recognition"), + picUrl: parseXmlField(xml, "PicUrl"), + locationX: parseXmlField(xml, "Location_X"), + locationY: parseXmlField(xml, "Location_Y"), + scale: parseXmlField(xml, "Scale"), + label: parseXmlField(xml, "Label"), + title: parseXmlField(xml, "Title"), + description: parseXmlField(xml, "Description"), + url: parseXmlField(xml, "Url"), + thumbMediaId: parseXmlField(xml, "ThumbMediaId"), + event: parseXmlField(xml, "Event").toLowerCase(), + eventKey: parseXmlField(xml, "EventKey"), + latitude: parseXmlField(xml, "Latitude"), + longitude: parseXmlField(xml, "Longitude"), + precision: parseXmlField(xml, "Precision"), + encrypt: parseXmlField(xml, "Encrypt"), + rawXml: xml + }; +} +function readJsonResponse2(response) { + return response.text().then((text) => { + if (!response.ok) { + throw new Error(text || `upstream ${response.status}`); + } + return text ? JSON.parse(text) : null; + }); +} +async function readJsonBody2(response) { + const text = await response.text(); + if (!text) return { payload: null, text: "" }; + try { + return { payload: JSON.parse(text), text }; + } catch { + return { payload: null, text }; + } +} +function sanitizeAsrMessage(message) { + if (!message || typeof message !== "string") return "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5"; + const trimmed = message.trim(); + if (!trimmed) return "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5"; + if (/Failed to load audio|ffmpeg|Invalid data found when processing input|moov atom not found/i.test(trimmed)) { + return "\u97F3\u9891\u683C\u5F0F\u65E0\u6CD5\u8BC6\u522B\uFF0C\u8BF7\u91CD\u65B0\u5F55\u5236"; + } + if (/timeout|timed out/i.test(trimmed)) return "\u8BC6\u522B\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5"; + if (trimmed.length > 160) return `${trimmed.slice(0, 160)}\u2026`; + return trimmed; +} +function guessVoiceMimeType(format = "", contentType = "") { + const normalizedContentType = String(contentType ?? "").split(";")[0].trim().toLowerCase(); + if (normalizedContentType) return normalizedContentType; + const normalizedFormat = String(format ?? "").trim().toLowerCase(); + if (normalizedFormat === "amr") return "audio/amr"; + if (normalizedFormat === "wav") return "audio/wav"; + if (normalizedFormat === "mp3") return "audio/mpeg"; + if (normalizedFormat === "m4a") return "audio/mp4"; + if (normalizedFormat === "ogg") return "audio/ogg"; + return "application/octet-stream"; +} +function deriveWechatEndpointFromUrl(sourceUrl, pathname) { + if (!sourceUrl) return ""; + try { + const url = new URL(sourceUrl); + url.pathname = pathname; + url.search = ""; + return url.toString(); + } catch { + return ""; + } +} +function createUserMessage2(text, metadata = {}) { + return { + id: crypto27.randomUUID(), + role: "user", + created: Math.floor(Date.now() / 1e3), + content: [{ type: "text", text }], + metadata: { + userVisible: true, + agentVisible: true, + ...metadata + } + }; +} +function messageVisibleText2(message) { + if (!message?.content) return ""; + return message.content.filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text).join(""); +} +function pushMessage2(messages, incoming) { + const last = messages[messages.length - 1]; + if (last?.id && incoming?.id && last.id === incoming.id) { + return [ + ...messages.slice(0, -1), + { + ...last, + content: mergeMessageContent(last.content, incoming.content) + } + ]; + } + return [...messages, incoming]; +} +async function executeSessionReply(apiFetch2, sessionId, requestId, prompt, metadata = {}) { + const eventsResponse = await apiFetch2(`/sessions/${sessionId}/events`, { + method: "GET", + headers: { Accept: "text/event-stream" } + }); + if (!eventsResponse.ok || !eventsResponse.body) { + const text = await eventsResponse.text().catch(() => ""); + throw new Error(text || "\u65E0\u6CD5\u5EFA\u7ACB\u516C\u4F17\u53F7\u6D88\u606F\u4E8B\u4EF6\u6D41"); + } + const replyResponse = await apiFetch2(`/sessions/${sessionId}/reply`, { + method: "POST", + body: JSON.stringify({ + request_id: requestId, + user_message: createUserMessage2(prompt, metadata) + }) + }); + if (!replyResponse.ok) { + const text = await replyResponse.text().catch(() => ""); + throw new Error(text || "Agent reply \u8BF7\u6C42\u5931\u8D25"); + } + replyResponse.body?.cancel().catch?.(() => { + }); + const reader = eventsResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let messages = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split("\n\n"); + buffer = frames.pop() ?? ""; + for (const frame of frames) { + let data = ""; + for (const line of frame.split("\n")) { + if (line.startsWith("data:")) data += line.slice(5).trim(); + } + if (!data) continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + const routingId = event.chat_request_id ?? event.request_id; + if (routingId && routingId !== requestId) continue; + if (event.type === "Message" && event.message?.metadata?.userVisible) { + const hasActionRequired = event.message.content?.some((item) => item.type === "actionRequired"); + if (hasActionRequired) { + throw new Error("\u5F53\u524D\u56DE\u590D\u9700\u8981\u4EBA\u5DE5\u786E\u8BA4\uFF0C\u516C\u4F17\u53F7\u901A\u9053\u6682\u4E0D\u652F\u6301"); + } + messages = pushMessage2(messages, event.message); + } else if (event.type === "UpdateConversation") { + messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + } else if (event.type === "Error") { + throw new Error(event.error || "\u4EFB\u52A1\u6267\u884C\u5931\u8D25"); + } else if (event.type === "Finish") { + const assistant = [...messages].reverse().find((item) => item.role === "assistant"); + return { + text: messageVisibleText2(assistant), + tokenState: event.token_state ?? null + }; + } + } + } + throw new Error("\u516C\u4F17\u53F7\u6D88\u606F\u4E8B\u4EF6\u6D41\u63D0\u524D\u7ED3\u675F"); +} +var WECHAT_CUSTOMER_TEXT_MAX_BYTES = 2048; +function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) { + const normalized = String(text ?? "").trim(); + if (!normalized) return ["\u6211\u5148\u6536\u5230\u4E86\uFF0C\u4F46\u8FD9\u6B21\u6CA1\u6709\u751F\u6210\u53EF\u53D1\u9001\u7684\u6587\u672C\u7ED3\u679C\u3002"]; + const chunks = []; + let offset = 0; + while (offset < normalized.length) { + let byteCount = 0; + let end = offset; + while (end < normalized.length) { + const charBytes = Buffer.byteLength(normalized[end], "utf8"); + if (byteCount + charBytes > maxBytes) break; + byteCount += charBytes; + end += 1; + } + if (end === offset) break; + chunks.push(normalized.slice(offset, end)); + offset = end; + } + return chunks.length > 0 ? chunks : ["\u6211\u5148\u6536\u5230\u4E86\uFF0C\u4F46\u8FD9\u6B21\u6CA1\u6709\u751F\u6210\u53EF\u53D1\u9001\u7684\u6587\u672C\u7ED3\u679C\u3002"]; +} +function stripInternalWechatUsername(text) { + return String(text ?? "").replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, ""); +} +function replaceLeadingInternalWechatUsername(text, user) { + const addressName = normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName); + if (!addressName) return stripInternalWechatUsername(text); + return String(text ?? "").replace( + /(^|[\s,,、::])wx_[a-z0-9_]{4,64}\s*([,,、::])\s*/gi, + (_match, prefix = "", punctuation = "\uFF0C") => `${prefix}${addressName}${punctuation}` + ); +} +function convertMarkdownLinks(text) { + return String(text ?? "").replace(/\[([^\]\n]{1,160})\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => { + const cleanLabel = String(label ?? "").trim(); + const cleanUrl = String(url ?? "").trim(); + return cleanLabel ? `${cleanLabel} +${cleanUrl}` : cleanUrl; + }); +} +function splitMarkdownTableRow(line) { + const trimmed = String(line ?? "").trim(); + if (!trimmed.includes("|")) return null; + return trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()); +} +function isMarkdownTableSeparator(line) { + const cells = splitMarkdownTableRow(line); + return Boolean(cells?.length) && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} +function convertMarkdownTables(text) { + const lines = String(text ?? "").split("\n"); + const output = []; + for (let index = 0; index < lines.length; index += 1) { + const headers = splitMarkdownTableRow(lines[index]); + if (!headers || !isMarkdownTableSeparator(lines[index + 1])) { + output.push(lines[index]); + continue; + } + const readableRows = []; + index += 2; + while (index < lines.length) { + const cells = splitMarkdownTableRow(lines[index]); + if (!cells) break; + const parts = cells.map((cell, cellIndex) => { + const header = headers[cellIndex] || `\u5217${cellIndex + 1}`; + return cell ? `${header}\uFF1A${cell}` : ""; + }).filter(Boolean); + if (parts.length) readableRows.push(`- ${parts.join("\uFF1B")}`); + index += 1; + } + index -= 1; + output.push(...readableRows); + } + return output.join("\n"); +} +function stripMarkdownEmphasis(text) { + return String(text ?? "").replace(/\*\*([^*\n]+)\*\*/g, "$1").replace(/__([^_\n]+)__/g, "$1").replace(/`([^`\n]+)`/g, "$1").replace(/\*/g, "").replace(/__/g, ""); +} +function formatWechatOutboundText(text, user = null) { + return replaceLeadingInternalWechatUsername( + stripMarkdownEmphasis(convertMarkdownTables(convertMarkdownLinks(text))).replace(/^\s*#{1,6}\s+/gm, "").replace(/^\s*---+\s*$/gm, "").replace(/\n{3,}/g, "\n\n"), + user + ); +} +function defaultPublicHtmlLinkExists(urlText) { + let url; + try { + url = new URL(urlText); + } catch { + return true; + } + const parts = url.pathname.split("/").filter(Boolean).map((part) => { + try { + return decodeURIComponent(part); + } catch { + return part; + } + }); + if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== "public") return true; + const owner = parts[1]; + const rest = parts.slice(3); + if (!owner || rest.some((part) => !part || part === "." || part === "..")) return false; + const root = path21.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, "public"); + const target = path21.resolve(root, ...rest); + if (target !== root && !target.startsWith(`${root}${path21.sep}`)) return false; + return fs22.existsSync(target) && fs22.statSync(target).isFile(); +} +async function guardMissingPublicHtmlLinks(text, { linkExists = defaultPublicHtmlLinkExists } = {}) { + const value = String(text ?? ""); + const replacements = []; + for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) { + const url = match[0]; + let exists = true; + try { + exists = await linkExists(url); + } catch { + exists = false; + } + if (!exists) { + const filename = match[2].split("/").pop() ?? "\u9875\u9762"; + replacements.push([ + url, + `\uFF08\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210\uFF0C\u5DF2\u963B\u6B62\u53D1\u9001\u5931\u6548\u94FE\u63A5\uFF1A${filename}\u3002\u8BF7\u91CD\u65B0\u751F\u6210\u9875\u9762\u540E\u518D\u6253\u5F00\u3002\uFF09` + ]); + } + } + return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value); +} +function isQuestionStatusProbe(text) { + return /^[??]+$/.test(String(text ?? "").trim()); +} +function isSimpleGreeting(text) { + const normalized = String(text ?? "").trim().replace(/[!!。.\s]+$/g, "").toLowerCase(); + return /^(你好|您好|在吗|在不在|嗨|hi|hello|hey)$/.test(normalized); +} +function isConnectivityTest(text) { + const normalized = String(text ?? "").trim().replace(/[!!。.\s]+$/g, ""); + return /^(测试\s*\d*|test\s*\d*)$/i.test(normalized); +} +function isTopicResetIntent(text) { + const normalized = String(text ?? "").trim(); + return /^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/i.test(normalized) || /忽略.*之前|不要管.*之前|别管.*之前/.test(normalized); +} +function normalizeNumber(value) { + if (value === "" || value === null || value === void 0) return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} +function normalizeWechatInboundIntent(inbound) { + const msgType = String(inbound?.msgType ?? "").toLowerCase(); + const base = { + appId: "", + openid: String(inbound?.fromUserName ?? "").trim(), + msgId: String(inbound?.msgId ?? "").trim(), + msgType, + displayText: "", + agentText: "", + raw: { ...inbound } + }; + if (msgType === "text") { + const content = String(inbound?.content ?? "").trim(); + return { + ...base, + displayText: content, + agentText: content + }; + } + if (msgType === "voice") { + const recognition = String(inbound?.recognition ?? "").trim(); + return { + ...base, + displayText: recognition ? `\u8BED\u97F3\uFF1A${recognition}` : "\u8BED\u97F3\u6D88\u606F", + agentText: recognition, + media: { + mediaId: inbound?.mediaId || "", + format: inbound?.format || "" + } + }; + } + if (msgType === "image") { + return { + ...base, + displayText: "\u6536\u5230\u56FE\u7247\uFF0C\u8BF7\u63CF\u8FF0\u4F60\u60F3\u8BA9\u6211\u600E\u4E48\u5904\u7406", + agentText: "", + media: { + mediaId: inbound?.mediaId || "", + picUrl: inbound?.picUrl || "" + } + }; + } + if (msgType === "location") { + const latitude = normalizeNumber(inbound?.locationX); + const longitude = normalizeNumber(inbound?.locationY); + const scale = normalizeNumber(inbound?.scale); + const label = String(inbound?.label ?? "").trim(); + const fragments = [ + label ? `\u7528\u6237\u53D1\u9001\u4E86\u4F4D\u7F6E\uFF1A${label}` : "\u7528\u6237\u53D1\u9001\u4E86\u4F4D\u7F6E", + latitude !== null ? `\u7EAC\u5EA6\uFF1A${latitude}` : "", + longitude !== null ? `\u7ECF\u5EA6\uFF1A${longitude}` : "", + scale !== null ? `\u7F29\u653E\uFF1A${scale}` : "" + ].filter(Boolean); + return { + ...base, + displayText: fragments.join("\uFF1B"), + agentText: fragments.join("\uFF1B"), + location: { + latitude, + longitude, + scale, + label + } + }; + } + if (msgType === "link") { + const title = String(inbound?.title ?? "").trim(); + const description = String(inbound?.description ?? "").trim(); + const url = String(inbound?.url ?? "").trim(); + const fragments = [ + "\u7528\u6237\u5206\u4EAB\u4E86\u94FE\u63A5", + title ? `\u6807\u9898\uFF1A${title}` : "", + description ? `\u63CF\u8FF0\uFF1A${description}` : "", + url ? `URL\uFF1A${url}` : "" + ].filter(Boolean); + return { + ...base, + displayText: fragments.join("\uFF1B"), + agentText: fragments.join("\uFF1B"), + link: { title, description, url } + }; + } + if (msgType === "video" || msgType === "shortvideo") { + return { + ...base, + displayText: msgType === "shortvideo" ? "\u6536\u5230\u5C0F\u89C6\u9891" : "\u6536\u5230\u89C6\u9891", + agentText: "", + media: { + mediaId: inbound?.mediaId || "", + thumbMediaId: inbound?.thumbMediaId || "" + } + }; + } + if (msgType === "event") { + return { + ...base, + displayText: inbound?.event ? `\u4E8B\u4EF6\uFF1A${inbound.event}` : "\u4E8B\u4EF6\u6D88\u606F", + agentText: "", + location: inbound?.event === "location" ? { + latitude: normalizeNumber(inbound?.latitude), + longitude: normalizeNumber(inbound?.longitude), + precision: normalizeNumber(inbound?.precision) + } : void 0 + }; + } + return base; +} +function buildWechatAgentPrompt(intent) { + const msgType = String(intent?.msgType ?? "text"); + const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content) ? [ + "\u3010\u65E5\u7A0B\u6280\u80FD\u8981\u6C42\u3011\u8FD9\u6761\u6D88\u606F\u6D89\u53CA\u5F85\u529E\u3001\u63D0\u9192\u6216\u65E5\u7A0B\u3002", + "\u5F00\u59CB\u524D\u5148\u52A0\u8F7D `schedule-assistant` skill\uFF0C\u5E76\u4E25\u683C\u6309 skill \u91CC\u7684\u8FB9\u754C\u6267\u884C\u3002", + "\u53EA\u6709\u5728 `schedule_create_item` / `schedule_create_reminder` \u7B49\u5DE5\u5177\u6210\u529F\u8FD4\u56DE\u540E\uFF0C\u624D\u80FD\u544A\u8BC9\u7528\u6237\u201C\u5DF2\u7ECF\u8BBE\u7F6E\u597D\u4E86\u201D\u3002", + intent?.msgId ? `\u8C03\u7528 schedule_create_item \u65F6\u5FC5\u987B\u4F20\u5165 sourceMessageId: ${intent.msgId}` : "", + "" + ].filter(Boolean).join("\n") : ""; + if (msgType === "voice") { + return [ + scheduleAssistantHint, + "\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u8BED\u97F3\u6D88\u606F\u3011\u7528\u6237\u901A\u8FC7\u8BED\u97F3\u8F93\u5165\uFF0C\u4EE5\u4E0B\u662F\u5FAE\u4FE1\u8BC6\u522B\u7ED3\u679C\u3002", + "", + `\u7528\u6237\u8BED\u97F3\u8BC6\u522B\u6587\u672C\uFF1A${String(intent?.agentText ?? "").trim()}` + ].filter(Boolean).join("\n"); + } + if (msgType === "image") { + return [ + scheduleAssistantHint, + "\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u56FE\u7247\u6D88\u606F\u3011\u7528\u6237\u53D1\u9001\u4E86\u56FE\u7247\u3002", + "\u5982\u7528\u6237\u6CA1\u6709\u660E\u786E\u8981\u6C42\uFF0C\u8BF7\u5148\u6839\u636E\u56FE\u7247\u5185\u5BB9\u7ED9\u51FA\u7B80\u77ED\u7406\u89E3\uFF0C\u5E76\u8BE2\u95EE\u4E0B\u4E00\u6B65\u3002", + "", + String(intent?.agentText ?? "").trim() + ].filter(Boolean).join("\n"); + } + if (msgType === "location") { + const location = intent?.location ?? {}; + return [ + scheduleAssistantHint, + "\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u4F4D\u7F6E\u6D88\u606F\u3011\u7528\u6237\u53D1\u9001\u4E86\u5F53\u524D\u4F4D\u7F6E\u3002", + location.label ? `\u5730\u5740\uFF1A${location.label}` : "", + location.latitude !== null && location.latitude !== void 0 ? `\u7EAC\u5EA6\uFF1A${location.latitude}` : "", + location.longitude !== null && location.longitude !== void 0 ? `\u7ECF\u5EA6\uFF1A${location.longitude}` : "", + "\u8BF7\u7ED3\u5408\u4F4D\u7F6E\u56DE\u7B54\u7528\u6237\u53EF\u80FD\u7684\u8DEF\u7EBF\u3001\u9644\u8FD1\u3001\u884C\u7A0B\u6216\u63D0\u9192\u9700\u6C42\uFF1B\u5982\u679C\u610F\u56FE\u4E0D\u660E\u786E\uFF0C\u5148\u7B80\u77ED\u8BE2\u95EE\u3002" + ].filter(Boolean).join("\n"); + } + if (msgType === "link") { + const link = intent?.link ?? {}; + return [ + scheduleAssistantHint, + "\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u94FE\u63A5\u6D88\u606F\u3011\u7528\u6237\u5206\u4EAB\u4E86\u94FE\u63A5\u3002", + link.title ? `\u6807\u9898\uFF1A${link.title}` : "", + link.description ? `\u63CF\u8FF0\uFF1A${link.description}` : "", + link.url ? `URL\uFF1A${link.url}` : "" + ].filter(Boolean).join("\n"); + } + const content = String(intent?.agentText ?? intent?.content ?? "").trim(); + const lines = []; + if (scheduleAssistantHint) lines.push(scheduleAssistantHint); + lines.push( + "\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u65B0\u6D88\u606F\u3011\u8BF7\u53EA\u56DE\u7B54\u4E0B\u9762\u8FD9\u6761\u7528\u6237\u6D88\u606F\uFF0C\u4E0D\u8981\u4E3B\u52A8\u5EF6\u7EED\u65E0\u5173\u7684\u5386\u53F2\u8BDD\u9898\u3002", + "\u82E5\u7528\u6237\u53EA\u662F\u5728\u6D4B\u8BD5\u8FDE\u901A\u6027\uFF0C\u8BF7\u4E00\u53E5\u8BDD\u786E\u8BA4\u6536\u5230\u5373\u53EF\uFF0C\u4E0D\u8981\u5C55\u5F00\u65E7\u4EFB\u52A1\u3002", + "", + `\u7528\u6237\u6D88\u606F\uFF1A${content}` + ); + return lines.join("\n"); +} +function looksLikeScheduleConfirmation(text) { + const compact = String(text ?? "").replace(/\s+/g, ""); + if (!compact) return false; + if (/(没有|没能|未能|无法|不能|失败|需要你|请补充|请告诉)/.test(compact)) return false; + return /(已经|已|现在).{0,12}(设置|安排|记录|加上|添加|创建).{0,12}(待办|提醒|日程|安排|闹钟)|设置好了|已经设置好了|已经加上/.test( + compact + ); +} +function buildConnectivityTestReply(user) { + const name = resolveWechatAddressName(user); + return name ? `${name}\uFF0C\u516C\u4F17\u53F7\u6D88\u606F\u901A\u9053\u6B63\u5E38\uFF0C\u6211\u6536\u5230\u4F60\u7684\u6D4B\u8BD5\u4E86\u3002` : "\u516C\u4F17\u53F7\u6D88\u606F\u901A\u9053\u6B63\u5E38\uFF0C\u6211\u6536\u5230\u4F60\u7684\u6D4B\u8BD5\u4E86\u3002"; +} +function normalizeWechatName(value) { + const name = String(value ?? "").trim(); + if (!name || /^wx_[a-z0-9_]{4,64}$/i.test(name)) return ""; + return name; +} +function resolveWechatAddressName(user) { + return normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName) || ""; +} +function buildGreetingText(user) { + const name = resolveWechatAddressName(user); + return name ? `\u4F60\u597D\uFF0C${name}\uFF01\u6211\u5728\u5462\uFF0C\u6709\u4EC0\u4E48\u9700\u8981\uFF1F` : "\u4F60\u597D\uFF01\u6211\u5728\u5462\uFF0C\u6709\u4EC0\u4E48\u9700\u8981\uFF1F"; +} +function buildStatusText(user, fallbackText) { + const name = resolveWechatAddressName(user); + if (!name) return fallbackText; + return `\u6211\u5728\u8FD9\u8FB9\uFF0C${name}\u3002\u4E0A\u4E00\u6761\u5982\u679C\u8FD8\u6CA1\u5B8C\u6210\uFF0C\u6211\u4F1A\u7EE7\u7EED\u628A\u7ED3\u679C\u53D1\u7ED9\u4F60\uFF1B\u4F60\u4E5F\u53EF\u4EE5\u76F4\u63A5\u8865\u4E00\u53E5\u8981\u6C42\u3002`; +} +function successResponse(task = null) { + return { + ok: true, + status: 200, + contentType: "text/plain; charset=utf-8", + body: "success", + ...task ? { task } : {} + }; +} +function loadWechatMpConfig(env = process.env) { + const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? ""; + const appSecret = env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? ""; + const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? ""; + const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, "") ?? ""; + const enabledFlag = env.H5_WECHAT_MP_ENABLED === "1"; + const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || "/auth/wechat/authorize?intent=login"; + return { + enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl), + appId, + appSecret, + token, + publicBaseUrl, + bindPath, + ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT, + ackRandomEnabled: env.H5_WECHAT_MP_ACK_RANDOM !== "0", + ackNicknameEnabled: env.H5_WECHAT_MP_ACK_NICKNAME !== "0", + progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT, + progressDelayMs: Math.max( + 0, + Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS) + ), + statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT, + unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT, + unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT, + tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL, + customerServiceUrl: env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL, + jsapiTicketUrl: env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() || deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), "/cgi-bin/ticket/getticket") || DEFAULT_WECHAT_JSAPI_TICKET_URL, + mediaPublicBaseUrl: env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, "") || publicBaseUrl, + maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)), + acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== "0", + acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== "0", + acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== "0", + acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== "0", + encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? "" + }; +} +function sha1Hex(parts) { + return crypto27.createHash("sha1").update([...parts].sort().join("")).digest("hex"); +} +function verifyWechatMpSignature({ token, timestamp, nonce, signature }) { + return sha1Hex([token, timestamp, nonce]) === String(signature ?? ""); +} +function verifyWechatMpMsgSignature({ token, timestamp, nonce, echoStr, msgSignature }) { + return sha1Hex([token, timestamp, nonce, echoStr]) === String(msgSignature ?? ""); +} +function decodeWechatMpAesKey(encodingAesKey) { + const key = Buffer.from(`${String(encodingAesKey ?? "").trim()}=`, "base64"); + if (key.length !== 32) { + throw new Error("invalid encoding aes key"); + } + return key; +} +function decryptWechatMpPayload({ encodingAesKey, appId, encrypted }) { + const key = decodeWechatMpAesKey(encodingAesKey); + const iv = key.subarray(0, 16); + const decipher = crypto27.createDecipheriv("aes-256-cbc", key, iv); + decipher.setAutoPadding(false); + const cipherBuf = Buffer.from(String(encrypted ?? ""), "base64"); + let decoded = Buffer.concat([decipher.update(cipherBuf), decipher.final()]); + const pad = decoded.at(-1); + if (!Number.isInteger(pad) || pad < 1 || pad > 32) { + throw new Error("invalid padding"); + } + decoded = decoded.subarray(0, decoded.length - pad); + const content = decoded.subarray(16); + const msgLen = content.readUInt32BE(0); + const msg = content.subarray(4, 4 + msgLen).toString("utf8"); + const receivedAppId = content.subarray(4 + msgLen).toString("utf8"); + if (receivedAppId !== String(appId ?? "")) { + throw new Error("appid mismatch"); + } + return msg; +} +function verifyWechatMpUrlChallenge(query = {}, config = {}) { + const timestamp = String(query.timestamp ?? ""); + const nonce = String(query.nonce ?? ""); + const echostr = String(query.echostr ?? ""); + const encryptType = String(query.encrypt_type ?? "").toLowerCase(); + if (encryptType === "aes") { + const msgSignature = String(query.msg_signature ?? ""); + if (!config?.token || !config?.encodingAesKey || !config?.appId) { + return { ok: false, status: 503, body: "wechat mp aes mode not configured" }; + } + if (!verifyWechatMpMsgSignature({ + token: config.token, + timestamp, + nonce, + echoStr: echostr, + msgSignature + })) { + return { ok: false, status: 403, body: "invalid signature" }; + } + try { + const plain = decryptWechatMpPayload({ + encodingAesKey: config.encodingAesKey, + appId: config.appId, + encrypted: echostr + }); + return { ok: true, status: 200, body: plain }; + } catch { + return { ok: false, status: 403, body: "invalid echostr" }; + } + } + if (!verifyWechatMpSignature({ + token: config?.token, + timestamp, + nonce, + signature: String(query.signature ?? "") + })) { + return { ok: false, status: 403, body: "invalid signature" }; + } + return { ok: true, status: 200, body: echostr }; +} +function buildWechatTextReply({ + toUserName, + fromUserName, + content, + createTime = Math.floor(Date.now() / 1e3) +}) { + return [ + "", + ``, + ``, + `${Number(createTime) || Math.floor(Date.now() / 1e3)}`, + "", + ``, + "" + ].join(""); +} +function createWechatMpService({ + config, + userAuth: userAuth2, + apiFetch: apiFetch2, + sessionApiFetch = null, + scheduleService: scheduleService2 = null, + applySessionLlmProvider = null, + wechatFetch = undiciFetch7, + linkExists = defaultPublicHtmlLinkExists, + logger = console +}) { + if (!config?.enabled) { + return { + enabled: false + }; + } + config = { + ...config, + tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL, + customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL, + jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL, + progressText: config.progressText ?? DEFAULT_PROGRESS_TEXT, + progressDelayMs: Math.max(0, Number(config.progressDelayMs ?? DEFAULT_PROGRESS_DELAY_MS)), + statusText: config.statusText || DEFAULT_STATUS_TEXT, + mediaPublicBaseUrl: config.mediaPublicBaseUrl || config.publicBaseUrl, + maxImageBytes: Math.max(1, Number(config.maxImageBytes ?? 10 * 1024 * 1024)), + acceptVoice: config.acceptVoice !== false, + acceptImage: config.acceptImage !== false, + acceptLocation: config.acceptLocation !== false, + acceptLink: config.acceptLink !== false, + asrTarget: config.asrTarget || DEFAULT_ASR_TARGET + }; + let accessTokenCache = { + token: null, + expiresAt: 0 + }; + let jsapiTicketCache = { + ticket: null, + expiresAt: 0 + }; + const fetchForSession = (sessionId, pathname, init) => sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch2(pathname, init); + const buildBindUrl = () => { + if (/^https?:\/\//i.test(config.bindPath)) return config.bindPath; + const path23 = config.bindPath.startsWith("/") ? config.bindPath : `/${config.bindPath}`; + return `${config.publicBaseUrl}${path23}`; + }; + const verifyRequest = (query = {}) => verifyWechatMpSignature({ + token: config.token, + timestamp: String(query.timestamp ?? ""), + nonce: String(query.nonce ?? ""), + signature: String(query.signature ?? "") + }); + const verifyUrlChallenge = (query = {}) => verifyWechatMpUrlChallenge(query, config); + const getStableAccessToken = async () => { + if (accessTokenCache.token && accessTokenCache.expiresAt > Date.now() + 6e4) { + return accessTokenCache.token; + } + const payload = await readJsonResponse2( + await wechatFetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "client_credential", + appid: config.appId, + secret: config.appSecret, + force_refresh: false + }) + }) + ); + if (!payload?.access_token) { + throw new Error(payload?.errmsg || "\u83B7\u53D6\u5FAE\u4FE1 access_token \u5931\u8D25"); + } + const expiresIn = Number(payload.expires_in ?? 7200); + accessTokenCache = { + token: payload.access_token, + expiresAt: Date.now() + expiresIn * 1e3 + }; + return accessTokenCache.token; + }; + const getJsapiTicket = async () => { + if (jsapiTicketCache.ticket && jsapiTicketCache.expiresAt > Date.now() + 6e4) { + return jsapiTicketCache.ticket; + } + const accessToken = await getStableAccessToken(); + const url = new URL(config.jsapiTicketUrl); + url.searchParams.set("access_token", accessToken); + url.searchParams.set("type", "jsapi"); + const payload = await readJsonResponse2( + await wechatFetch(url.toString(), { + method: "GET", + headers: { Accept: "application/json" } + }) + ); + if (Number(payload?.errcode ?? 0) !== 0 || !payload?.ticket) { + throw new Error(payload?.errmsg || `\u83B7\u53D6\u5FAE\u4FE1 jsapi_ticket \u5931\u8D25 (${payload?.errcode})`); + } + const expiresIn = Number(payload.expires_in ?? 7200); + jsapiTicketCache = { + ticket: payload.ticket, + expiresAt: Date.now() + expiresIn * 1e3 + }; + return jsapiTicketCache.ticket; + }; + const createJsSdkSignature = async (pageUrl) => { + const normalizedUrl = String(pageUrl ?? "").split("#")[0]; + if (!/^https?:\/\//i.test(normalizedUrl)) { + throw new Error("\u7B7E\u540D URL \u5FC5\u987B\u662F\u5B8C\u6574 http(s) \u5730\u5740"); + } + const ticket = await getJsapiTicket(); + const nonceStr = crypto27.randomBytes(12).toString("hex"); + const timestamp = Math.floor(Date.now() / 1e3); + const signature = crypto27.createHash("sha1").update( + [ + `jsapi_ticket=${ticket}`, + `noncestr=${nonceStr}`, + `timestamp=${timestamp}`, + `url=${normalizedUrl}` + ].join("&") + ).digest("hex"); + return { + appId: config.appId, + timestamp, + nonceStr, + signature, + url: normalizedUrl, + jsApiList: ["startRecord", "stopRecord", "onVoiceRecordEnd", "translateVoice"] + }; + }; + const sendCustomerServiceText = async (openid, content, user = null) => { + const formatted = formatWechatOutboundText(content, user); + const guarded = await guardMissingPublicHtmlLinks(formatted, { linkExists }); + const chunks = splitWechatText(guarded); + const accessToken = await getStableAccessToken(); + for (const chunk of chunks) { + const payload = await readJsonResponse2( + await wechatFetch( + `${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: openid, + msgtype: "text", + text: { + content: chunk + } + }) + } + ) + ); + if (Number(payload?.errcode ?? 0) !== 0) { + const errcode = Number(payload?.errcode ?? 0); + const errmsg = String(payload?.errmsg ?? "").trim() || "unknown_error"; + throw new Error(`\u5FAE\u4FE1\u5BA2\u670D\u6D88\u606F\u53D1\u9001\u5931\u8D25 errcode=${errcode} errmsg=${errmsg}`); + } + } + }; + const sendTextToUser = async (userId, content) => { + const openid = await userAuth2.getWechatOpenidForUser(userId, config.appId); + if (!openid) { + throw new Error("\u7528\u6237\u5C1A\u672A\u7ED1\u5B9A\u670D\u52A1\u53F7\uFF0C\u65E0\u6CD5\u63A8\u9001\u63D0\u9192"); + } + await sendCustomerServiceText(openid, content); + }; + const ensureSessionProvider = async (sessionId) => { + if (!applySessionLlmProvider || !sessionId) return; + const applied = await applySessionLlmProvider(sessionId); + if (applied && applied.ok === false) { + throw new Error(applied.message || "\u4E13\u5C5E Agent Provider \u672A\u914D\u7F6E"); + } + }; + const transcribeWechatVoiceMedia = async (mediaId, format) => { + if (!mediaId) return ""; + const accessToken = await getStableAccessToken(); + const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch }); + if (!downloaded.buffer?.length) return ""; + const extension = String(format ?? "").trim().toLowerCase() || "amr"; + const form = new FormData(); + form.append( + "file", + new Blob([downloaded.buffer], { + type: guessVoiceMimeType(format, downloaded.contentType) + }), + `wechat-voice.${extension}` + ); + const response = await wechatFetch(`${config.asrTarget.replace(/\/$/, "")}/asr/oneshot`, { + method: "POST", + body: form + }); + const { payload, text } = await readJsonBody2(response); + if (!response.ok) { + throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? `ASR HTTP ${response.status}`)); + } + if (Number(payload?.code ?? 200) !== 200) { + throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5")); + } + return String(payload?.data?.text ?? "").trim(); + }; + const ensureWechatAgentSession = async ({ + userId, + openid, + forceNew = false, + userContext = null + }) => { + if (forceNew) { + await userAuth2.clearWechatAgentRoute(config.appId, openid); + } + const existingRoute = await userAuth2.getWechatAgentRoute(config.appId, openid); + if (existingRoute?.agentSessionId) { + return existingRoute.agentSessionId; + } + const gate = await userAuth2.canUseChat(userId); + if (!gate.ok) { + throw new Error(gate.message || "\u5F53\u524D\u7528\u6237\u65E0\u6CD5\u4F7F\u7528\u804A\u5929\u80FD\u529B"); + } + const workingDir = await userAuth2.resolveWorkingDir(userId); + const sessionPolicy = await userAuth2.getAgentSessionPolicy(userId); + const publishLayout = await userAuth2.getUserPublishLayout(userId); + const started = await readJsonResponse2( + await apiFetch2("/agent/start", { + method: "POST", + body: JSON.stringify({ + working_dir: workingDir, + enable_context_memory: sessionPolicy.enableContextMemory, + ...sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {} + }) + }) + ); + const sessionId = started?.id; + if (!sessionId) { + throw new Error("\u516C\u4F17\u53F7\u4E13\u5C5E Agent \u4F1A\u8BDD\u521B\u5EFA\u5931\u8D25"); + } + const addressName = resolveWechatAddressName(userContext); + await reconcileAgentSession( + (pathname, init) => fetchForSession(sessionId, pathname, init), + sessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout ? { + userId, + displayName: addressName || publishLayout.displayName, + username: addressName || null, + slug: null + } : null, + tolerateInvalidWorkingDir: true + } + ); + await userAuth2.upsertWechatAgentRoute({ + userId, + appId: config.appId, + openid, + agentSessionId: sessionId + }); + return sessionId; + }; + const rememberWechatUserContext = async (sessionId, user) => { + const addressName = resolveWechatAddressName(user); + if (!addressName) return; + try { + await readJsonResponse2( + await fetchForSession(sessionId, "/agent/harness_remember", { + method: "POST", + body: JSON.stringify({ + sessionId, + title: "\u5FAE\u4FE1\u670D\u52A1\u53F7\u7528\u6237", + content: [ + `\u5F53\u524D\u670D\u52A1\u53F7\u7528\u6237\u540D\u79F0\uFF1A${addressName}`, + "\u8FD9\u662F\u901A\u8FC7\u5FAE\u4FE1 openid \u5728 H5 \u7ED1\u5B9A\u5E93\u4E2D\u67E5\u8BE2\u5230\u7684\u7528\u6237\u540D\u79F0\u3002", + "\u56DE\u590D\u65F6\u53EF\u4EE5\u81EA\u7136\u4F7F\u7528\u8BE5\u540D\u79F0\u79F0\u547C\u7528\u6237\uFF0C\u4E0D\u8981\u4F7F\u7528 openid \u6216 wx_ \u5F00\u5934\u7684\u5185\u90E8\u7528\u6237\u540D\u3002" + ].join("\n") + }) + }) + ); + await readJsonResponse2( + await fetchForSession(sessionId, "/agent/harness_bootstrap", { + method: "POST", + body: JSON.stringify({ sessionId, force: true }) + }) + ); + } catch (err) { + logger.warn?.("WeChat MP user context remember failed:", err); + } + }; + const buildIntentMetadata = (intent) => ({ + source: "wechat_mp", + msgType: intent.msgType, + originalMsgId: intent.msgId || null, + displayText: intent.displayText || "", + mediaPublicUrl: intent.media?.publicUrl || null, + recognition: intent.msgType === "voice" ? intent.agentText || null : null, + location: intent.location || null, + link: intent.link || null + }); + const persistIntentDetail = async ({ intent, userId = null, rawXmlHash = "" }) => { + if (typeof userAuth2.insertWechatMpMessageDetail !== "function") return; + await userAuth2.insertWechatMpMessageDetail({ + appId: config.appId, + openid: intent.openid, + msgId: intent.msgId || null, + userId, + msgType: intent.msgType, + displayText: intent.displayText || "", + agentText: intent.agentText || "", + mediaId: intent.media?.mediaId || null, + mediaUrl: intent.media?.picUrl || null, + mediaPublicUrl: intent.media?.publicUrl || null, + mediaFormat: intent.media?.format || null, + locationLat: intent.location?.latitude ?? null, + locationLng: intent.location?.longitude ?? null, + locationLabel: intent.location?.label || null, + linkUrl: intent.link?.url || null, + linkTitle: intent.link?.title || null, + rawXmlHash, + rawJson: intent.raw + }); + }; + const runIntentMessage = async ({ inbound, intent, user }) => { + const resetCandidate = intent.msgType === "text" || intent.msgType === "voice" ? intent.agentText : ""; + const forceNew = isTopicResetIntent(resetCandidate); + let sessionId = await ensureWechatAgentSession({ + userId: user.userId, + openid: inbound.fromUserName, + userContext: user, + forceNew + }); + await ensureSessionProvider(sessionId); + await rememberWechatUserContext(sessionId, user); + let finished = false; + let progressTimer = null; + if (config.progressDelayMs > 0 && config.progressText) { + progressTimer = setTimeout(() => { + if (finished) return; + sendCustomerServiceText(inbound.fromUserName, config.progressText, user).catch((err) => { + logger.warn?.("WeChat MP progress reply failed:", err); + }); + }, config.progressDelayMs); + progressTimer.unref?.(); + } + const requestId = crypto27.randomUUID(); + const guardScheduleReply = async (replyText) => { + if (!scheduleService2 || !looksLikeScheduleConfirmation(replyText)) return replyText; + const sourceMessageId = String(intent.msgId ?? "").trim(); + if (!sourceMessageId || typeof scheduleService2.listItemsBySourceMessage !== "function") { + return replyText; + } + const items = await scheduleService2.listItemsBySourceMessage({ + userId: user.userId, + sourceMessageId, + limit: 5 + }).catch((err) => { + logger.warn?.("Schedule confirmation guard failed:", err); + return []; + }); + if (items.length > 0) return replyText; + return [ + "\u6211\u521A\u624D\u6CA1\u6709\u786E\u8BA4\u5230\u5F85\u529E/\u63D0\u9192\u5DF2\u7ECF\u5199\u5165\u7CFB\u7EDF\uFF0C\u6240\u4EE5\u8FD9\u6B21\u4E0D\u80FD\u7B97\u8BBE\u7F6E\u6210\u529F\u3002", + "\u8BF7\u518D\u53D1\u4E00\u6B21\u5B8C\u6574\u5B89\u6392\uFF0C\u6BD4\u5982\u201C\u660E\u5929\u65E9\u4E0A 6 \u70B9\u8DD1\u6B65\uFF0C5 \u70B9\u534A\u63D0\u9192\u6211\u201D\u3002\u6211\u4F1A\u5728\u5DE5\u5177\u5199\u5165\u6210\u529F\u540E\u518D\u786E\u8BA4\u3002" + ].join("\n"); + }; + try { + const reply = await executeSessionReply( + (pathname, init) => fetchForSession(sessionId, pathname, init), + sessionId, + requestId, + buildWechatAgentPrompt(intent), + buildIntentMetadata(intent) + ); + if (reply.tokenState) { + await userAuth2.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); + } + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); + return { sessionId }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const mayBeStaleSession = /403|404|not found|无权访问|session/i.test(message) && sessionId; + if (mayBeStaleSession) { + sessionId = await ensureWechatAgentSession({ + userId: user.userId, + openid: inbound.fromUserName, + forceNew: true, + userContext: user + }); + await ensureSessionProvider(sessionId); + await rememberWechatUserContext(sessionId, user); + const retryId = crypto27.randomUUID(); + const reply = await executeSessionReply( + (pathname, init) => fetchForSession(sessionId, pathname, init), + sessionId, + retryId, + buildWechatAgentPrompt(intent), + buildIntentMetadata(intent) + ); + if (reply.tokenState) { + await userAuth2.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); + } + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); + return { sessionId }; + } + throw err; + } finally { + finished = true; + if (progressTimer) clearTimeout(progressTimer); + } + }; + const handleScheduleIntent = async ({ intent, user }) => { + if (!scheduleService2) return null; + const scheduleIntent = parseScheduleIntent(intent.agentText); + if (!isScheduleIntent(scheduleIntent)) return null; + if (scheduleIntent.action === "create_todo") { + if (scheduleIntent.needsClarification?.includes("todo_title")) { + return "\u53EF\u4EE5\u3002\u4F60\u60F3\u8BA9\u6211\u8BB0\u54EA\u4E00\u6761\u5F85\u529E\uFF1F\u4F8B\u5982\u201C\u5E2E\u6211\u8BB0\u4E00\u4E0B \u8DDF\u6BB5\u5403\u996D\u201D\u3002"; + } + const item = await scheduleService2.createItem({ + userId: user.userId, + kind: "task", + title: scheduleIntent.title, + timezone: process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai", + sourceChannel: "wechat", + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText, + metadata: { + source: "wechat_mp" + } + }); + return `\u5DF2\u8BB0\u5F55\u5230\u5F85\u529E\u5217\u8868\uFF1A${item.title}\uFF0C\u672A\u8BBE\u7F6E\u63D0\u9192\u3002`; + } + if (scheduleIntent.action === "create_daily_todo_digest") { + if (scheduleIntent.needsClarification?.includes("digest_time")) { + return "\u53EF\u4EE5\u3002\u4F60\u60F3\u6BCF\u5929\u51E0\u70B9\u6536\u5230\u5F53\u5929\u5F85\u529E\u8BB0\u5F55\uFF1F\u6BD4\u5982\u201C\u6BCF\u5929\u65E9\u4E0A 7 \u70B9\u53D1\u7ED9\u6211\u201D\u3002"; + } + const subscription = await scheduleService2.createDailyTodoDigest({ + userId: user.userId, + hour: scheduleIntent.hour, + minute: scheduleIntent.minute, + timezone: process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai", + channel: "wechat", + sourceChannel: "wechat", + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText + }); + const minuteText = subscription.minute === 0 ? "" : `${String(subscription.minute).padStart(2, "0")}\u5206`; + return `\u5DF2\u8BBE\u7F6E\uFF1A\u6211\u4F1A\u6BCF\u5929\u65E9\u4E0A ${subscription.hour}\u70B9${minuteText} \u901A\u8FC7\u670D\u52A1\u53F7\u628A\u5F53\u5929\u5F85\u529E\u8BB0\u5F55\u53D1\u7ED9\u4F60\u3002`; + } + if (scheduleIntent.action === "create_balance_alert") { + if (scheduleIntent.needsClarification?.includes("threshold")) { + return "\u53EF\u4EE5\u3002\u4F60\u60F3\u5728\u4F59\u989D\u4F4E\u4E8E\u591A\u5C11\u65F6\u63D0\u9192\u6211\uFF1F\u4F8B\u5982\u201C\u4F59\u989D\u4F4E\u4E8E 20 \u5143\u63D0\u9192\u6211\u201D\u3002"; + } + const subscription = await scheduleService2.createBalanceLowAlert({ + userId: user.userId, + thresholdCents: scheduleIntent.thresholdCents, + channel: "wechat", + sourceChannel: "wechat", + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText + }); + return `\u5DF2\u8BBE\u7F6E\uFF1A\u5F53\u4F59\u989D\u4F4E\u4E8E ${(subscription.thresholdCents / 100).toFixed(2)} \u5143\u65F6\uFF0C\u6211\u4F1A\u901A\u8FC7\u670D\u52A1\u53F7\u63D0\u9192\u4F60\u3002`; + } + if (scheduleIntent.action === "query_schedule") { + const text = await scheduleService2.buildTodoDigestText({ + userId: user.userId, + timezone: process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai" + }); + return text; + } + return null; + }; + const handleInboundMessage = async (bodyText, query = {}) => { + if (!verifyRequest(query)) { + return { ok: false, status: 403, body: "invalid signature" }; + } + const inbound = parseWechatMessage(String(bodyText ?? "")); + const rawXmlHash = crypto27.createHash("sha1").update(inbound.rawXml || "").digest("hex"); + const intent = normalizeWechatInboundIntent(inbound); + intent.appId = config.appId; + if (!inbound.fromUserName || !inbound.toUserName || !inbound.msgType) { + return { ok: false, status: 400, body: "invalid xml" }; + } + if (String(query.encrypt_type ?? "").toLowerCase() === "aes" || inbound.encrypt) { + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: "\u5F53\u524D\u516C\u4F17\u53F7\u56DE\u8C03\u9700\u4F7F\u7528\u660E\u6587\u6A21\u5F0F\u63A5\u5165\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5207\u6362\u540E\u518D\u8BD5\u3002" + }) + }; + } + if (inbound.msgType === "event" && inbound.event === "location") { + await persistIntentDetail({ intent, rawXmlHash }); + return successResponse(); + } + if (inbound.msgType === "event" && inbound.event === "subscribe") { + await persistIntentDetail({ intent, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: `${config.unboundTextPrefix} +${buildBindUrl()}` + }) + }; + } + if (inbound.msgType === "event") { + await persistIntentDetail({ intent, rawXmlHash }); + return successResponse(); + } + const supportedByConfig = intent.msgType === "voice" && config.acceptVoice || intent.msgType === "image" && config.acceptImage || intent.msgType === "location" && config.acceptLocation || intent.msgType === "link" && config.acceptLink || intent.msgType === "text" || intent.msgType === "video" || intent.msgType === "shortvideo"; + if (!supportedByConfig) { + await persistIntentDetail({ intent, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: config.unsupportedText + }) + }; + } + const boundUser = await userAuth2.findWechatUserByOpenid(config.appId, inbound.fromUserName); + if (!boundUser) { + await persistIntentDetail({ intent, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: `${config.unboundTextPrefix} +${buildBindUrl()}` + }) + }; + } + if (boundUser.status === "disabled") { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: "\u5F53\u524D\u8D26\u53F7\u4E0D\u53EF\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5904\u7406\u3002" + }) + }; + } + if (intent.msgType === "voice" && !intent.agentText.trim() && intent.media?.mediaId) { + try { + const fallbackText = await transcribeWechatVoiceMedia(intent.media.mediaId, intent.media.format); + if (fallbackText) { + intent.agentText = fallbackText; + intent.displayText = `\u8BED\u97F3\uFF1A${fallbackText}`; + } + } catch (err) { + logger.warn?.("WeChat MP voice ASR fallback failed:", err); + } + } + if (intent.msgType === "text" && !intent.agentText.trim()) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: config.unsupportedText + }) + }; + } + if (intent.msgType === "voice" && !intent.agentText.trim()) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: "\u672A\u8BC6\u522B\u5230\u8BED\u97F3\u6587\u5B57\uFF0C\u8BF7\u518D\u8BF4\u4E00\u6B21\u6216\u8F93\u5165\u6587\u5B57\u3002" + }) + }; + } + if (intent.msgType === "image") { + try { + const accessToken = await getStableAccessToken(); + const persisted = await persistWechatImage( + { + userId: boundUser.userId, + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId, + mediaId: inbound.mediaId, + picUrl: inbound.picUrl, + publicBaseUrl: config.mediaPublicBaseUrl, + maxImageBytes: config.maxImageBytes + }, + { + wechatFetch, + accessToken + } + ); + intent.media = { + ...intent.media, + mediaId: inbound.mediaId || intent.media?.mediaId || "", + picUrl: inbound.picUrl || intent.media?.picUrl || "", + publicUrl: persisted.publicUrl, + format: persisted.contentType, + source: persisted.source + }; + intent.agentText = `[\u56FE\u72471]: ${persisted.publicUrl}`; + } catch (error) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: "\u56FE\u7247\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u6216\u76F4\u63A5\u53D1\u9001\u6587\u5B57\u8BF4\u660E\u3002" + }) + }; + } + } + if (intent.msgType === "location" && (intent.location?.latitude == null || intent.location?.longitude == null)) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: "\u8FD9\u6B21\u4F4D\u7F6E\u5B57\u6BB5\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u4F4D\u7F6E\u6216\u76F4\u63A5\u8F93\u5165\u5730\u70B9\u3002" + }) + }; + } + if (intent.msgType === "link" && !intent.link?.url) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: "\u8FD9\u6B21\u6CA1\u6709\u62FF\u5230\u5B8C\u6574\u94FE\u63A5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u4E00\u6B21\u3002" + }) + }; + } + if (intent.msgType === "video" || intent.msgType === "shortvideo") { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: "\u5DF2\u6536\u5230\u89C6\u9891\uFF0C\u5F53\u524D\u5148\u652F\u6301\u6587\u672C\u3001\u8BED\u97F3\u3001\u56FE\u7247\u3001\u5B9A\u4F4D\u548C\u94FE\u63A5\u3002" + }) + }; + } + if (intent.msgType === "text" && isQuestionStatusProbe(intent.agentText)) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: buildStatusText(boundUser, config.statusText) + }) + }; + } + if (intent.msgType === "text" && isSimpleGreeting(intent.agentText)) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: buildGreetingText(boundUser) + }) + }; + } + if ((intent.msgType === "text" || intent.msgType === "voice") && isConnectivityTest(intent.agentText)) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: buildConnectivityTestReply(boundUser) + }) + }; + } + if (inbound.msgId && typeof userAuth2.recordWechatMpMessage === "function") { + const recorded = await userAuth2.recordWechatMpMessage({ + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId + }); + if (!recorded?.inserted) { + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: buildStatusText(boundUser, config.statusText) + }) + }; + } + } + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + const scheduleReply = intent.msgType === "text" || intent.msgType === "voice" ? await handleScheduleIntent({ intent, user: boundUser }) : null; + if (scheduleReply) { + if (inbound.msgId && typeof userAuth2.finishWechatMpMessage === "function") { + await userAuth2.finishWechatMpMessage({ + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId, + status: "done", + agentSessionId: null + }); + } + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: scheduleReply + }) + }; + } + const task = runIntentMessage({ + inbound, + intent, + user: boundUser + }).then(async ({ sessionId } = {}) => { + if (inbound.msgId && typeof userAuth2.finishWechatMpMessage === "function") { + await userAuth2.finishWechatMpMessage({ + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId, + status: "done", + agentSessionId: sessionId + }); + } + }).catch(async (err) => { + if (inbound.msgId && typeof userAuth2.finishWechatMpMessage === "function") { + await userAuth2.finishWechatMpMessage({ + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId, + status: "failed" + }).catch(() => { + }); + } + logger.error?.("WeChat MP background reply failed:", err); + return sendCustomerServiceText( + inbound.fromUserName, + `\u8FD9\u6B21\u8F6C\u53D1\u5230\u4E13\u5C5E Agent \u5931\u8D25\u4E86\uFF1A${err instanceof Error ? err.message : String(err)}`, + boundUser + ).catch(() => { + }); + }); + return { + ok: true, + status: 200, + contentType: "application/xml; charset=utf-8", + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: buildAckText({ + intent, + nickname: resolveWechatAddressName(boundUser), + config, + fallbackText: config.ackText + }) + }), + task + }; + }; + const getRouteStatusForUser = async (userId) => { + const openid = await userAuth2.getWechatOpenidForUser(userId, config.appId); + if (!openid) { + return { + enabled: true, + bound: false, + appId: config.appId, + openid: null, + agentSessionId: null, + routeStatus: null, + updatedAt: null + }; + } + const route = await userAuth2.getWechatAgentRoute(config.appId, openid); + return { + enabled: true, + bound: true, + appId: config.appId, + openid, + agentSessionId: route?.agentSessionId ?? null, + routeStatus: route?.status ?? null, + updatedAt: route?.updatedAt ?? null + }; + }; + const recreateRouteForUser = async (userId) => { + const openid = await userAuth2.getWechatOpenidForUser(userId, config.appId); + if (!openid) { + return { + ok: false, + message: "\u5F53\u524D\u8D26\u53F7\u5C1A\u672A\u7ED1\u5B9A\u8BE5\u670D\u52A1\u53F7" + }; + } + const sessionId = await ensureWechatAgentSession({ + userId, + openid, + forceNew: true + }); + const route = await userAuth2.getWechatAgentRoute(config.appId, openid); + return { + ok: true, + route: { + enabled: true, + bound: true, + appId: config.appId, + openid, + agentSessionId: sessionId, + routeStatus: route?.status ?? "active", + updatedAt: route?.updatedAt ?? null + } + }; + }; + return { + enabled: true, + verifyRequest, + verifyUrlChallenge, + handleInboundMessage, + getRouteStatusForUser, + recreateRouteForUser, + createJsSdkSignature, + sendTextToUser + }; +} + +// schedule-service.mjs +import crypto28 from "node:crypto"; + +// schedule-time.mjs +var DEFAULT_TIMEZONE = "Asia/Shanghai"; +function pad2(value) { + return String(value).padStart(2, "0"); +} +function normalizeTimezone(timezone) { + return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE; +} +function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: normalizeTimezone(timezone), + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(epochMs)).map((part) => [part.type, part.value]) + ); + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour === "24" ? 0 : parts.hour), + minute: Number(parts.minute), + second: Number(parts.second) + }; +} +function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + return `${parts.month}\u6708${parts.day}\u65E5`; +} +function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + return zonedTimeToEpochMs( + { + year: parts.year, + month: parts.month, + day: parts.day, + hour: 0, + minute: 0, + second: 0 + }, + timezone + ); +} +function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + const utc = Date.UTC(parts.year, parts.month - 1, parts.day + Number(days || 0), parts.hour, parts.minute, parts.second); + const shifted = new Date(utc); + return zonedTimeToEpochMs( + { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth() + 1, + day: shifted.getUTCDate(), + hour: shifted.getUTCHours(), + minute: shifted.getUTCMinutes(), + second: shifted.getUTCSeconds() + }, + timezone + ); +} +function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) { + const tz = normalizeTimezone(timezone); + let guess = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour ?? 0), + Number(parts.minute ?? 0), + Number(parts.second ?? 0), + 0 + ); + for (let i = 0; i < 3; i += 1) { + const actual = getLocalParts(guess, tz); + const actualAsUtc = Date.UTC( + actual.year, + actual.month - 1, + actual.day, + actual.hour, + actual.minute, + actual.second, + 0 + ); + const desiredAsUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour ?? 0), + Number(parts.minute ?? 0), + Number(parts.second ?? 0), + 0 + ); + const delta = actualAsUtc - desiredAsUtc; + if (delta === 0) break; + guess -= delta; + } + return guess; +} +function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) { + const tz = normalizeTimezone(timezone); + const todayStart = startOfLocalDay(now, tz); + const todayParts = getLocalParts(todayStart, tz); + let runAt = zonedTimeToEpochMs( + { + year: todayParts.year, + month: todayParts.month, + day: todayParts.day, + hour: Number(hour), + minute: Number(minute), + second: 0 + }, + tz + ); + if (runAt <= now) { + const tomorrowStart = addLocalDays(todayStart, 1, tz); + const tomorrowParts = getLocalParts(tomorrowStart, tz); + runAt = zonedTimeToEpochMs( + { + year: tomorrowParts.year, + month: tomorrowParts.month, + day: tomorrowParts.day, + hour: Number(hour), + minute: Number(minute), + second: 0 + }, + tz + ); + } + return runAt; +} +function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) { + const parts = getLocalParts(epochMs, timezone); + return `${pad2(parts.hour)}:${pad2(parts.minute)}`; +} + +// schedule-service.mjs +var DEFAULT_TIMEZONE2 = "Asia/Shanghai"; +function nowMs() { + return Date.now(); +} +function rowToItem(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + kind: row.kind, + title: row.title, + description: row.description ?? null, + status: row.status, + startAt: row.start_at == null ? null : Number(row.start_at), + endAt: row.end_at == null ? null : Number(row.end_at), + dueAt: row.due_at == null ? null : Number(row.due_at), + allDay: Boolean(row.all_day), + timezone: row.timezone || DEFAULT_TIMEZONE2, + location: row.location ?? null, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function rowToDigest(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + hour: Number(row.hour), + minute: Number(row.minute), + timezone: row.timezone || DEFAULT_TIMEZONE2, + channel: row.channel || "wechat", + status: row.status, + nextRunAt: Number(row.next_run_at), + lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at), + attempts: Number(row.attempts ?? 0) + }; +} +function rowToBalanceAlert(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + thresholdCents: Number(row.threshold_cents), + channel: row.channel || "wechat", + status: row.status, + nextRunAt: Number(row.next_run_at), + lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at), + lastNotifiedBalanceCents: row.last_notified_balance_cents == null ? null : Number(row.last_notified_balance_cents), + attempts: Number(row.attempts ?? 0), + lastError: row.last_error ?? null, + lockedUntil: row.locked_until == null ? null : Number(row.locked_until), + sourceChannel: row.source_channel ?? null, + sourceSessionId: row.source_session_id ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null + }; +} +function rowToReminder(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + itemId: row.item_id, + remindAt: Number(row.remind_at), + offsetMinutes: row.offset_minutes == null ? null : Number(row.offset_minutes), + channel: row.channel || "wechat", + status: row.status, + attempts: Number(row.attempts ?? 0), + lastError: row.last_error ?? null, + lockedUntil: row.locked_until == null ? null : Number(row.locked_until), + sentAt: row.sent_at == null ? null : Number(row.sent_at), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function parseJsonColumn(value) { + if (value == null || value === "") return null; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return null; + } + } + if (typeof value === "object") return value; + return null; +} +function rowToUserNotification(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + channel: row.channel, + notificationType: row.notification_type, + title: row.title, + body: row.body, + data: parseJsonColumn(row.data_json), + status: row.status, + readAt: row.read_at == null ? null : Number(row.read_at), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; +} +function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE2 } = {}) { + const date = localDateLabel(now, timezone); + if (!items.length) { + return `${date} \u5F85\u529E\u8BB0\u5F55 + +\u4ECA\u5929\u6682\u65F6\u6CA1\u6709\u5F85\u529E\u3002`; + } + const lines = [`${date} \u5F85\u529E\u8BB0\u5F55`, ""]; + items.forEach((item, index) => { + const time = item.dueAt || item.startAt ? `\uFF08${formatLocalTime(item.dueAt || item.startAt, timezone)}\uFF09` : ""; + lines.push(`${index + 1}. ${item.title}${time}`); + }); + return lines.join("\n"); +} +function createScheduleService(pool, options = {}) { + const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE); + const clock = options.clock || { now: nowMs }; + const createItem = async ({ + userId, + kind = "task", + title, + description = null, + startAt = null, + endAt = null, + dueAt = null, + allDay = false, + timezone = defaultTimezone, + location = null, + sourceChannel = "agent", + sourceSessionId = null, + sourceMessageId = null, + sourceText = null, + metadata = null + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const cleanTitle = String(title ?? "").trim(); + if (!cleanTitle) throw new Error("\u7F3A\u5C11\u5F85\u529E\u6807\u9898"); + const safeKind = kind === "event" ? "event" : "task"; + const id = crypto28.randomUUID(); + const ts = clock.now(); + await pool.query( + `INSERT INTO h5_schedule_items + (id, user_id, kind, title, description, status, start_at, end_at, due_at, all_day, + timezone, location, source_channel, source_session_id, source_message_id, source_text, + metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + userId, + safeKind, + cleanTitle, + description, + startAt, + endAt, + dueAt, + allDay ? 1 : 0, + normalizeTimezone(timezone), + location, + sourceChannel, + sourceSessionId, + sourceMessageId, + sourceText, + metadata ? JSON.stringify(metadata) : null, + ts, + ts + ] + ); + return { + id, + userId, + kind: safeKind, + title: cleanTitle, + startAt, + endAt, + dueAt, + allDay: Boolean(allDay), + timezone: normalizeTimezone(timezone), + status: "active", + createdAt: ts, + updatedAt: ts + }; + }; + const listItems = async ({ userId, from = null, to = null, status = "active", limit = 100 } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?", "deleted_at IS NULL"]; + const params = [userId]; + if (status) { + clauses.push("status = ?"); + params.push(status); + } + if (from != null && to != null) { + clauses.push( + `( + (start_at IS NOT NULL AND start_at >= ? AND start_at < ?) + OR (due_at IS NOT NULL AND due_at >= ? AND due_at < ?) + OR (start_at IS NULL AND due_at IS NULL) + )` + ); + params.push(from, to, from, to); + } + params.push(Math.max(1, Math.min(500, Number(limit) || 100))); + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_items + WHERE ${clauses.join(" AND ")} + ORDER BY COALESCE(start_at, due_at, 9223372036854775807), created_at + LIMIT ?`, + params + ); + return rows.map(rowToItem); + }; + const listItemsBySourceMessage = async ({ userId, sourceMessageId, limit = 20 } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const cleanSourceMessageId = String(sourceMessageId ?? "").trim(); + if (!cleanSourceMessageId) return []; + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_items + WHERE user_id = ? + AND source_message_id = ? + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT ?`, + [userId, cleanSourceMessageId, Math.max(1, Math.min(100, Number(limit) || 20))] + ); + return rows.map(rowToItem); + }; + const listTodayTodoItems = async ({ userId, timezone = defaultTimezone, now = clock.now() } = {}) => { + const start = startOfLocalDay(now, timezone); + const end = addLocalDays(start, 1, timezone); + return listItems({ userId, from: start, to: end, status: "active", limit: 200 }); + }; + const createReminder = async ({ + userId, + itemId, + remindAt, + offsetMinutes = null, + channel = "wechat" + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + if (!itemId) throw new Error("\u7F3A\u5C11\u4E8B\u9879"); + const safeRemindAt = Number(remindAt); + if (!Number.isFinite(safeRemindAt) || safeRemindAt <= 0) { + throw new Error("\u63D0\u9192\u65F6\u95F4\u65E0\u6548"); + } + const safeOffsetMinutes = offsetMinutes == null || offsetMinutes === "" ? null : Number(offsetMinutes); + if (safeOffsetMinutes != null && !Number.isInteger(safeOffsetMinutes)) { + throw new Error("\u63D0\u9192\u504F\u79FB\u5206\u949F\u65E0\u6548"); + } + const safeChannel = channel === "in_app" ? "in_app" : "wechat"; + const [itemRows] = await pool.query( + `SELECT id + FROM h5_schedule_items + WHERE id = ? AND user_id = ? AND deleted_at IS NULL + LIMIT 1`, + [itemId, userId] + ); + if (!itemRows[0]) throw new Error("\u4E8B\u9879\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE"); + const id = crypto28.randomUUID(); + const ts = clock.now(); + await pool.query( + `INSERT INTO h5_schedule_reminders + (id, user_id, item_id, remind_at, offset_minutes, channel, status, attempts, + last_error, locked_until, sent_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, NULL, ?, ?) + ON DUPLICATE KEY UPDATE + offset_minutes = VALUES(offset_minutes), + status = 'pending', + last_error = NULL, + locked_until = NULL, + updated_at = VALUES(updated_at)`, + [id, userId, itemId, safeRemindAt, safeOffsetMinutes, safeChannel, ts, ts] + ); + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_reminders + WHERE item_id = ? AND remind_at = ? AND channel = ? + LIMIT 1`, + [itemId, safeRemindAt, safeChannel] + ); + return rowToReminder(rows[0]) || { + id, + userId, + itemId, + remindAt: safeRemindAt, + offsetMinutes: safeOffsetMinutes, + channel: safeChannel, + status: "pending", + attempts: 0, + lastError: null, + lockedUntil: null, + sentAt: null, + createdAt: ts, + updatedAt: ts + }; + }; + const listDigestSubscriptions = async ({ + userId, + digestType = "todo_day", + channel = null, + status = null, + limit = 20 + } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?", "digest_type = ?"]; + const params = [userId, digestType]; + if (channel) { + clauses.push("channel = ?"); + params.push(channel); + } + if (status) { + const statuses = Array.isArray(status) ? status : [status]; + clauses.push(`status IN (${statuses.map(() => "?").join(", ")})`); + params.push(...statuses); + } + params.push(Math.max(1, Math.min(200, Number(limit) || 20))); + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_digest_subscriptions + WHERE ${clauses.join(" AND ")} + ORDER BY next_run_at ASC, created_at DESC + LIMIT ?`, + params + ); + return rows.map(rowToDigest); + }; + const createDailyTodoDigest = async ({ + userId, + hour, + minute = 0, + timezone = defaultTimezone, + channel = "wechat", + sourceChannel = "agent", + sourceSessionId = null, + sourceMessageId = null, + sourceText = null + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const safeHour = Number(hour); + const safeMinute = Number(minute); + if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) { + throw new Error("\u63D0\u9192\u5C0F\u65F6\u65E0\u6548"); + } + if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) { + throw new Error("\u63D0\u9192\u5206\u949F\u65E0\u6548"); + } + const tz = normalizeTimezone(timezone); + const nextRunAt = nextDailyRunAt({ + hour: safeHour, + minute: safeMinute, + timezone: tz, + now: clock.now() + }); + const id = crypto28.randomUUID(); + const ts = clock.now(); + await pool.query( + `INSERT INTO h5_schedule_digest_subscriptions + (id, user_id, digest_type, hour, minute, timezone, channel, status, next_run_at, + source_channel, source_session_id, source_message_id, source_text, created_at, updated_at) + VALUES (?, ?, 'todo_day', ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + hour = VALUES(hour), + minute = VALUES(minute), + timezone = VALUES(timezone), + channel = VALUES(channel), + status = 'active', + next_run_at = VALUES(next_run_at), + source_channel = VALUES(source_channel), + source_session_id = VALUES(source_session_id), + source_message_id = VALUES(source_message_id), + source_text = VALUES(source_text), + updated_at = VALUES(updated_at)`, + [ + id, + userId, + safeHour, + safeMinute, + tz, + channel, + nextRunAt, + sourceChannel, + sourceSessionId, + sourceMessageId, + sourceText, + ts, + ts + ] + ); + const [rows] = await pool.query( + `SELECT * FROM h5_schedule_digest_subscriptions + WHERE user_id = ? AND digest_type = 'todo_day' AND channel = ? + LIMIT 1`, + [userId, channel] + ); + return rowToDigest(rows[0]) || { + id, + userId, + hour: safeHour, + minute: safeMinute, + timezone: tz, + channel, + status: "active", + nextRunAt, + lastRunAt: null, + attempts: 0 + }; + }; + const createBalanceLowAlert = async ({ + userId, + thresholdCents, + channel = "wechat", + sourceChannel = "agent", + sourceSessionId = null, + sourceMessageId = null, + sourceText = null + } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const safeThreshold = Number(thresholdCents); + if (!Number.isFinite(safeThreshold) || safeThreshold < 0) { + throw new Error("\u4F59\u989D\u9608\u503C\u65E0\u6548"); + } + const now = clock.now(); + const id = crypto28.randomUUID(); + await pool.query( + `INSERT INTO h5_balance_alert_subscriptions + (id, user_id, threshold_cents, channel, status, next_run_at, last_run_at, + last_notified_balance_cents, attempts, locked_until, last_error, + source_channel, source_session_id, source_message_id, source_text, created_at, updated_at) + VALUES (?, ?, ?, ?, 'active', ?, NULL, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + threshold_cents = VALUES(threshold_cents), + channel = VALUES(channel), + status = 'active', + next_run_at = VALUES(next_run_at), + last_error = NULL, + locked_until = NULL, + updated_at = VALUES(updated_at)`, + [id, userId, safeThreshold, channel, now, sourceChannel, sourceSessionId, sourceMessageId, sourceText, now, now] + ); + const [rows] = await pool.query( + `SELECT * FROM h5_balance_alert_subscriptions WHERE user_id = ? AND channel = ? LIMIT 1`, + [userId, channel] + ); + return rowToBalanceAlert(rows[0]) || { + id, + userId, + thresholdCents: safeThreshold, + channel, + status: "active", + nextRunAt: now, + lastRunAt: null, + lastNotifiedBalanceCents: null, + attempts: 0 + }; + }; + const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => { + const [rows] = await pool.query( + `SELECT * + FROM h5_balance_alert_subscriptions + WHERE next_run_at <= ? + AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?)) + ORDER BY next_run_at ASC + LIMIT ?`, + [now, now, Math.max(1, Math.min(200, Number(limit) || 50))] + ); + return rows.map(rowToBalanceAlert); + }; + const lockBalanceAlert = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { + const lockedUntil = now + lockMs; + const [result] = await pool.query( + `UPDATE h5_balance_alert_subscriptions + SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? + WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`, + [lockedUntil, now, id, now] + ); + if (Number(result?.affectedRows ?? 0) !== 1) return null; + const [rows] = await pool.query( + `SELECT * FROM h5_balance_alert_subscriptions WHERE id = ? LIMIT 1`, + [id] + ); + return rowToBalanceAlert(rows[0]); + }; + const markBalanceAlertSent = async (subscription, { now = clock.now() } = {}) => { + await pool.query( + `UPDATE h5_balance_alert_subscriptions + SET status = 'active', next_run_at = ?, last_run_at = ?, last_notified_balance_cents = ?, + locked_until = NULL, last_error = NULL, updated_at = ? + WHERE id = ?`, + [now + 5 * 60 * 1e3, now, subscription.lastNotifiedBalanceCents ?? null, now, subscription.id] + ); + return { ...subscription, status: "active", lastRunAt: now }; + }; + const markBalanceAlertFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => { + const attempts = Number(subscription.attempts ?? 0); + const status = attempts >= maxAttempts ? "failed" : "active"; + const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt; + await pool.query( + `UPDATE h5_balance_alert_subscriptions + SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ? + WHERE id = ?`, + [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id] + ); + }; + const listDueDigestSubscriptions = async ({ now = clock.now(), limit = 50 } = {}) => { + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_digest_subscriptions + WHERE next_run_at <= ? + AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?)) + ORDER BY next_run_at ASC + LIMIT ?`, + [now, now, Math.max(1, Math.min(200, Number(limit) || 50))] + ); + return rows.map(rowToDigest); + }; + const lockDigestSubscription = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { + const lockedUntil = now + lockMs; + const [result] = await pool.query( + `UPDATE h5_schedule_digest_subscriptions + SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? + WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`, + [lockedUntil, now, id, now] + ); + if (Number(result?.affectedRows ?? 0) !== 1) return null; + const [rows] = await pool.query( + `SELECT * FROM h5_schedule_digest_subscriptions WHERE id = ? LIMIT 1`, + [id] + ); + return rowToDigest(rows[0]); + }; + const markDigestSent = async (subscription, { now = clock.now() } = {}) => { + const nextRunAt = nextDailyRunAt({ + hour: subscription.hour, + minute: subscription.minute, + timezone: subscription.timezone, + now: now + 1e3 + }); + await pool.query( + `UPDATE h5_schedule_digest_subscriptions + SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL, + last_error = NULL, updated_at = ? + WHERE id = ?`, + [nextRunAt, now, now, subscription.id] + ); + return { ...subscription, status: "active", nextRunAt, lastRunAt: now }; + }; + const markDigestFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => { + const attempts = Number(subscription.attempts ?? 0); + const status = attempts >= maxAttempts ? "failed" : "active"; + const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt; + await pool.query( + `UPDATE h5_schedule_digest_subscriptions + SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ? + WHERE id = ?`, + [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id] + ); + }; + const logDelivery = async ({ subscriptionId, userId, channel = "wechat", status, providerMessageId = null, errorCode = null, errorMessage = null }) => { + await pool.query( + `INSERT INTO h5_schedule_delivery_logs + (id, reminder_id, subscription_id, user_id, channel, status, provider_message_id, + error_code, error_message, created_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + crypto28.randomUUID(), + subscriptionId, + userId, + channel, + status, + providerMessageId, + errorCode, + errorMessage ? String(errorMessage).slice(0, 500) : null, + clock.now() + ] + ); + }; + const buildTodoDigestText = async ({ userId, timezone = defaultTimezone, now = clock.now() }) => { + const items = await listTodayTodoItems({ userId, timezone, now }); + return formatTodoDigest(items, { now, timezone }); + }; + const getUserWalletSnapshot = async (userId) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const [rows] = await pool.query( + `SELECT balance_cents AS balanceCents, tokens_used AS tokensUsed + FROM h5_user_wallets + WHERE user_id = ? + LIMIT 1`, + [userId] + ); + return rows[0] ?? null; + }; + const createUserNotification = async ({ + userId, + channel = "web", + notificationType, + title, + body, + data = null + }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + if (!notificationType) throw new Error("\u7F3A\u5C11\u901A\u77E5\u7C7B\u578B"); + if (!title) throw new Error("\u7F3A\u5C11\u901A\u77E5\u6807\u9898"); + if (!body) throw new Error("\u7F3A\u5C11\u901A\u77E5\u5185\u5BB9"); + const now = clock.now(); + const id = crypto28.randomUUID(); + await pool.query( + `INSERT INTO h5_user_notifications + (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', NULL, ?, ?)`, + [id, userId, channel, notificationType, title, body, data ? JSON.stringify(data) : null, now, now] + ); + return { id, userId, channel, notificationType, title, body, data, status: "unread", readAt: null, createdAt: now, updatedAt: now }; + }; + const listUserNotifications = async ({ userId, status = "unread", limit = 20 } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?"]; + const params = [userId]; + if (status && status !== "all") { + clauses.push("status = ?"); + params.push(status); + } + params.push(Math.max(1, Math.min(100, Number(limit) || 20))); + const [rows] = await pool.query( + `SELECT * + FROM h5_user_notifications + WHERE ${clauses.join(" AND ")} + ORDER BY created_at DESC + LIMIT ?`, + params + ); + return rows.map(rowToUserNotification); + }; + const markUserNotificationRead = async ({ userId, notificationId }) => { + if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570"); + const now = clock.now(); + const [result] = await pool.query( + `UPDATE h5_user_notifications + SET status = 'read', read_at = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND status <> 'read'`, + [now, now, notificationId, userId] + ); + return Number(result?.affectedRows ?? 0) > 0; + }; + const markAllUserNotificationsRead = async ({ userId } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const now = clock.now(); + const [result] = await pool.query( + `UPDATE h5_user_notifications + SET status = 'read', read_at = ?, updated_at = ? + WHERE user_id = ? AND status <> 'read'`, + [now, now, userId] + ); + return Number(result?.affectedRows ?? 0); + }; + const deleteUserNotification = async ({ userId, notificationId } = {}) => { + if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570"); + const [result] = await pool.query( + `DELETE FROM h5_user_notifications + WHERE id = ? AND user_id = ?`, + [notificationId, userId] + ); + return Number(result?.affectedRows ?? 0) > 0; + }; + const clearUserNotifications = async ({ userId, status = "all" } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const clauses = ["user_id = ?"]; + const params = [userId]; + if (status && status !== "all") { + clauses.push("status = ?"); + params.push(status); + } + const [result] = await pool.query( + `DELETE FROM h5_user_notifications + WHERE ${clauses.join(" AND ")}`, + params + ); + return Number(result?.affectedRows ?? 0); + }; + return { + createItem, + createReminder, + listItems, + listItemsBySourceMessage, + listTodayTodoItems, + listDigestSubscriptions, + createDailyTodoDigest, + createBalanceLowAlert, + listDueDigestSubscriptions, + lockDigestSubscription, + markDigestSent, + markDigestFailed, + listDueBalanceAlerts, + lockBalanceAlert, + markBalanceAlertSent, + markBalanceAlertFailed, + logDelivery, + buildTodoDigestText, + getUserWalletSnapshot, + createUserNotification, + listUserNotifications, + markUserNotificationRead, + markAllUserNotificationsRead, + deleteUserNotification, + clearUserNotifications + }; +} + +// schedule-reminder-worker.mjs +function startScheduleReminderWorker({ + scheduleService: scheduleService2, + sendWechatTextToUser, + logger = console, + intervalMs = Number(process.env.H5_REMINDER_SCAN_INTERVAL_MS ?? 3e4), + maxAttempts = Number(process.env.H5_REMINDER_MAX_ATTEMPTS ?? 5), + runOnStart = true +} = {}) { + if (!scheduleService2 || typeof sendWechatTextToUser !== "function") { + return { stop() { + } }; + } + let stopped = false; + let running = false; + const runOnce = async () => { + if (running || stopped) return; + running = true; + try { + const due = await scheduleService2.listDueDigestSubscriptions({ limit: 50 }); + for (const candidate of due) { + const subscription = await scheduleService2.lockDigestSubscription(candidate.id); + if (!subscription) continue; + try { + const text = await scheduleService2.buildTodoDigestText({ + userId: subscription.userId, + timezone: subscription.timezone + }); + await scheduleService2.createUserNotification?.({ + userId: subscription.userId, + channel: "web", + notificationType: "todo_digest", + title: "\u4ECA\u65E5\u5F85\u529E\u6458\u8981", + body: text, + data: { + subscriptionId: subscription.id, + timezone: subscription.timezone + } + }); + await sendWechatTextToUser(subscription.userId, text); + await scheduleService2.logDelivery({ + subscriptionId: subscription.id, + userId: subscription.userId, + channel: subscription.channel, + status: "success" + }); + await scheduleService2.markDigestSent(subscription); + } catch (err) { + logger.warn?.("Schedule digest delivery failed:", err); + await scheduleService2.logDelivery({ + subscriptionId: subscription.id, + userId: subscription.userId, + channel: subscription.channel, + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err) + }).catch(() => { + }); + await scheduleService2.markDigestFailed(subscription, err, { maxAttempts }); + } + } + const dueBalance = await scheduleService2.listDueBalanceAlerts({ limit: 50 }); + for (const candidate of dueBalance) { + const subscription = await scheduleService2.lockBalanceAlert(candidate.id); + if (!subscription) continue; + try { + const user = await scheduleService2.getUserWalletSnapshot?.(subscription.userId); + const balanceCents = Number(user?.balanceCents ?? user?.balance_cents ?? 0); + if (balanceCents > subscription.thresholdCents) { + await scheduleService2.markBalanceAlertSent({ + ...subscription, + lastNotifiedBalanceCents: balanceCents + }); + continue; + } + const yuan = (subscription.thresholdCents / 100).toFixed(2); + const text = `\u4F59\u989D\u4E0D\u8DB3\u63D0\u9192 + +\u4F60\u7684\u8D26\u6237\u4F59\u989D\u5DF2\u4F4E\u4E8E ${yuan} \u5143\uFF0C\u8BF7\u53CA\u65F6\u5145\u503C\u3002`; + await scheduleService2.createUserNotification?.({ + userId: subscription.userId, + channel: "web", + notificationType: "balance_low", + title: "\u4F59\u989D\u4E0D\u8DB3\u63D0\u9192", + body: text, + data: { + thresholdCents: subscription.thresholdCents, + balanceCents + } + }); + await sendWechatTextToUser(subscription.userId, text); + await scheduleService2.logDelivery({ + subscriptionId: subscription.id, + userId: subscription.userId, + channel: subscription.channel, + status: "success" + }); + await scheduleService2.markBalanceAlertSent({ + ...subscription, + lastNotifiedBalanceCents: balanceCents + }); + } catch (err) { + logger.warn?.("Balance alert delivery failed:", err); + await scheduleService2.logDelivery({ + subscriptionId: subscription.id, + userId: subscription.userId, + channel: subscription.channel, + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err) + }).catch(() => { + }); + await scheduleService2.markBalanceAlertFailed(subscription, err, { maxAttempts }); + } + } + } catch (err) { + logger.warn?.("Schedule reminder worker failed:", err); + } finally { + running = false; + } + }; + const timer = setInterval(runOnce, Math.max(1e3, Number(intervalMs) || 3e4)); + timer.unref?.(); + if (runOnStart) void runOnce(); + return { + runOnce, + stop() { + stopped = true; + clearInterval(timer); + } + }; +} + +// session-snapshot.mjs +function createSessionSnapshotService(pool) { + function isEnabled() { + return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== "0"; + } + async function save(sessionId, userId, session, messages) { + if (!isEnabled() || !pool) return; + try { + const now = Date.now(); + const sessionMeta = { + name: session.name ?? "", + working_dir: session.working_dir ?? "", + created_at_str: session.created_at ?? "", + updated_at_str: session.updated_at ?? "", + user_set_name: session.user_set_name ? 1 : 0, + recipe_json: session.recipe != null ? JSON.stringify(session.recipe) : null + }; + await pool.query( + `INSERT INTO h5_session_snapshots + (agent_session_id, user_id, name, working_dir, + created_at_str, updated_at_str, user_set_name, recipe_json, + synced_msg_count, source_updated_at, + messages_json, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + working_dir = VALUES(working_dir), + created_at_str = VALUES(created_at_str), + updated_at_str = VALUES(updated_at_str), + user_set_name = VALUES(user_set_name), + recipe_json = VALUES(recipe_json), + synced_msg_count = VALUES(synced_msg_count), + source_updated_at = VALUES(source_updated_at), + messages_json = VALUES(messages_json), + synced_at = VALUES(synced_at)`, + [ + sessionId, + userId, + sessionMeta.name, + sessionMeta.working_dir, + sessionMeta.created_at_str, + sessionMeta.updated_at_str, + sessionMeta.user_set_name, + sessionMeta.recipe_json, + session.message_count ?? 0, + session.updated_at ?? "", + JSON.stringify(messages), + now + ] + ); + } catch (err) { + console.warn("[snapshot] save failed:", err instanceof Error ? err.message : err); + } + } + async function get(sessionId) { + if (!isEnabled() || !pool) return null; + try { + const [rows] = await pool.query( + `SELECT agent_session_id, user_id, name, working_dir, + created_at_str, updated_at_str, user_set_name, recipe_json, + synced_msg_count, source_updated_at, messages_json, synced_at + FROM h5_session_snapshots + WHERE agent_session_id = ? + LIMIT 1`, + [sessionId] + ); + if (!rows.length) return null; + const row = rows[0]; + return { + session: { + id: sessionId, + name: row.name, + working_dir: row.working_dir, + message_count: row.synced_msg_count, + created_at: row.created_at_str || void 0, + updated_at: row.updated_at_str || void 0, + user_set_name: row.user_set_name === 1, + recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null, + conversation: null + }, + messages: JSON.parse(row.messages_json), + meta: { + synced_msg_count: row.synced_msg_count, + source_updated_at: row.source_updated_at, + synced_at: row.synced_at + } + }; + } catch (err) { + console.warn("[snapshot] get failed:", err instanceof Error ? err.message : err); + return null; + } + } + async function remove(sessionId) { + if (!pool) return; + try { + await pool.query( + `DELETE FROM h5_session_snapshots WHERE agent_session_id = ?`, + [sessionId] + ); + } catch (err) { + console.warn("[snapshot] remove failed:", err instanceof Error ? err.message : err); + } + } + async function refresh(sessionId, userId, apiFetchFn) { + if (!isEnabled() || !pool) return; + try { + const res = await apiFetchFn(`/sessions/${encodeURIComponent(sessionId)}`, { + method: "GET" + }); + if (!res.ok) return; + const gooseSession = await res.json(); + const messages = (gooseSession.conversation ?? []).filter((m) => m.metadata?.userVisible); + await save(sessionId, userId, gooseSession, messages); + } catch (err) { + console.warn("[snapshot] refresh failed:", sessionId, err instanceof Error ? err.message : err); + } + } + return { save, get, remove, refresh, isEnabled }; +} + +// asr-proxy.mjs +import express from "express"; +import { fetch as fetch4 } from "undici"; +var ASR_TARGET = process.env.H5_ASR_TARGET ?? "https://asr.tkmind.cn"; +var ASR_MAX_BYTES = Number(process.env.H5_ASR_MAX_BYTES ?? 5 * 1024 * 1024); +var ASR_TIMEOUT_MS = Number(process.env.H5_ASR_TIMEOUT_MS ?? 45e3); +function sanitizeAsrMessage2(message) { + if (!message || typeof message !== "string") return "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5"; + const trimmed = message.trim(); + if (!trimmed) return "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5"; + if (/Failed to load audio|ffmpeg|Invalid data found when processing input|moov atom not found/i.test(trimmed)) { + return "\u97F3\u9891\u683C\u5F0F\u65E0\u6CD5\u8BC6\u522B\uFF0C\u8BF7\u91CD\u65B0\u5F55\u5236"; + } + if (/timeout|timed out/i.test(trimmed)) return "\u8BC6\u522B\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5"; + if (trimmed.length > 160) return `${trimmed.slice(0, 160)}\u2026`; + return trimmed; +} +function attachAsrRoutes(api2, deps) { + const multipartRaw = express.raw({ + type: (req) => (req.headers["content-type"] ?? "").includes("multipart/form-data"), + limit: ASR_MAX_BYTES + }); + api2.post("/asr/oneshot", multipartRaw, async (req, res) => { + const contentType = req.headers["content-type"] ?? ""; + if (!contentType.includes("multipart/form-data")) { + return deps.sendError(res, req, 400, "invalid_request", "Content-Type \u5FC5\u987B\u4E3A multipart/form-data"); + } + if (!req.body?.length) { + return deps.sendError(res, req, 400, "invalid_request", "\u97F3\u9891\u5185\u5BB9\u4E3A\u7A7A"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ASR_TIMEOUT_MS); + try { + const upstream = await fetch4(`${ASR_TARGET}/asr/oneshot`, { + method: "POST", + headers: { "content-type": contentType }, + body: req.body, + signal: controller.signal + }); + const text = await upstream.text(); + let payload = null; + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + if (!upstream.ok) { + const message = sanitizeAsrMessage2(payload?.message ?? text ?? "ASR \u8BF7\u6C42\u5931\u8D25"); + console.warn("[asr] upstream HTTP error", upstream.status, message); + return deps.sendError(res, req, upstream.status, "asr_failed", message); + } + if (payload?.code !== 200) { + const message = sanitizeAsrMessage2(payload?.message ?? "\u8BC6\u522B\u5931\u8D25"); + console.warn("[asr] upstream business error", payload?.code, message); + return deps.sendError(res, req, 502, "asr_failed", message); + } + return deps.sendData(res, req, { text: payload?.data?.text ?? "" }); + } catch (err) { + const message = err instanceof Error && err.name === "AbortError" ? "\u8BC6\u522B\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5" : sanitizeAsrMessage2(err instanceof Error ? err.message : "ASR \u8BF7\u6C42\u5931\u8D25"); + console.warn("[asr] proxy failed", message); + return deps.sendError(res, req, 502, "asr_failed", message); + } finally { + clearTimeout(timer); + } + }); +} + +// server.mjs +var __dirname5 = path22.dirname(fileURLToPath6(import.meta.url)); +function loadEnvFile(filePath) { + if (!fs23.existsSync(filePath)) return; + for (const line of fs23.readFileSync(filePath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} +loadEnvFile(path22.join(__dirname5, "../../.env.local")); +loadEnvFile(path22.join(__dirname5, ".env")); +var PORT = Number(process.env.H5_PORT ?? 8081); +var API_TARGET = process.env.TKMIND_API_TARGET ?? "https://127.0.0.1:18006"; +var API_TARGETS = [ + API_TARGET, + ...process.env.TKMIND_API_TARGET_1 ? [process.env.TKMIND_API_TARGET_1] : [] +]; +var API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"; +var INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; +var ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; +var WECHAT_MP_CONFIG = loadWechatMpConfig(); +var WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== "0"; +var USERS_ROOT = process.env.H5_USERS_ROOT ?? path22.join(__dirname5, "users"); +var app = express2(); +app.set("trust proxy", 1); +var isSecureRequest = (req) => req.secure || req.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https"; +var 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 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]; + 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", "\u6765\u6E90\u6821\u9A8C\u5931\u8D25"); + } + return next(); +} +app.use("/api", csrfOriginCheck); +app.use("/auth", csrfOriginCheck); +var jsonBody = express2.json({ limit: "1mb" }); +var jsonUnlessMultipart = (req, res, next) => { + if ((req.headers["content-type"] ?? "").includes("multipart/form-data")) return next(); + return jsonBody(req, res, next); +}; +var rawUploadBody = express2.raw({ + type: "application/octet-stream", + limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) +}); +var wikiAuth = createWikiAuth(path22.join(__dirname5, PUBLISH_ROOT_DIR, "wiki-db")); +var legacyAuth = null; +if (ACCESS_PASSWORD) { + legacyAuth = createAuthManager({ password: ACCESS_PASSWORD }); +} +var userAuth = null; +var tkmindProxy = null; +var sessionSnapshotService = null; +var mindSpace = null; +var mindSpaceAssets = null; +var mindSpaceAudit = null; +var mindSpacePages = null; +var mindSpacePageLiveEdit = null; +var mindSpacePageEditSession = null; +var mindSpacePublications = null; +var plazaPosts = null; +var plazaEvents = null; +var plazaRecommend = null; +var plazaInteractions = null; +var plazaSeo = null; +var plazaOps = null; +var plazaRedis = createNoopPlazaRedis(); +var mindSpaceCleanup = null; +var mindSpaceAgentJobs = null; +var mindSpaceAgentRunner = null; +var rechargeService = null; +var subscriptionService = null; +var wechatPayClient = null; +var wechatOAuthService = null; +var wechatMpService = null; +var scheduleService = null; +var scheduleReminderWorker = null; +var llmProviderService = null; +var wordFilterService = null; +var authPool = null; +async function bootstrapUserAuth() { + try { + if (!isDatabaseConfigured()) return false; + 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 ?? 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 ?? 1e3), + scheduleService + }); + mindSpaceAssets = createAssetService(pool, { + h5Root: __dirname5, + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) + }); + mindSpacePages = createPageService(pool, { + h5Root: __dirname5, + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace") + }); + mindSpacePageLiveEdit = createPageLiveEditService({ + pageService: mindSpacePages, + 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; + } + }); + mindSpacePublications = createPublicationService(pool, { + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5) + }); + 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 = createCleanupService(pool, { + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + h5Root: __dirname5 + }); + 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: __dirname5, + defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), + subscriptionService + }); + 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 = createAgentJobService(pool, { + pageService: mindSpacePages, + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) + }); + mindSpaceAgentRunner = createMindSpaceAgentRunner({ + apiTarget: API_TARGET, + apiSecret: API_SECRET, + userAuth, + agentJobService: mindSpaceAgentJobs + }); + mindSpaceAudit = createMindSpaceAuditWriter(pool); + if (WORKSPACE_MAINTENANCE_ENABLED) { + startWorkspaceThumbnailWatcher(path22.join(__dirname5, PUBLISH_ROOT_DIR)); + startWorkspaceAssetSyncWatcher({ + publishRoot: path22.join(__dirname5, PUBLISH_ROOT_DIR), + syncUserWorkspaceByDirKey: async (dirKey, options) => { + 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; + } + if (!userId) return; + await mindSpaceAssets.syncWorkspaceAssets(userId, options); + } + }); + void mindSpaceAssets.expireStaleUploads().catch(() => { + }); + setInterval(() => { + void mindSpaceAssets?.expireStaleUploads().catch(() => { + }); + }, 5 * 60 * 1e3).unref?.(); + } else { + console.log("Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)"); + } + await userAuth.ensureAdminUser(); + llmProviderService = createLlmProviderService(pool, { + apiTarget: API_TARGET, + apiSecret: API_SECRET + }); + 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); + }); + 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 fs23.promises.readFile(assetPath); + return { buffer, mimeType: asset.mimeType }; + } : null + }); + wechatMpService = createWechatMpService({ + config: WECHAT_MP_CONFIG, + userAuth, + apiFetch: tkmindProxy.apiFetch, + sessionApiFetch: async (sessionId, pathname, init) => { + const target = await tkmindProxy.resolveTarget(sessionId); + 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} +${body}`.trim()); + }); + if (process.env.H5_REMINDER_WORKER_ENABLED === "1" && wechatMpService?.enabled && scheduleService) { + scheduleReminderWorker = startScheduleReminderWorker({ + scheduleService, + sendWechatTextToUser: (userId, text) => wechatMpService.sendTextToUser(userId, text) + }); + 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 * 1e3); + subExpiryTimer.unref?.(); + } + mindSpacePageEditSession = createPageEditSessionService({ + apiTarget: API_TARGET, + apiSecret: API_SECRET, + userAuth, + 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); + return false; + } +} +var 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) { + res.set( + "Set-Cookie", + clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)) + ); +} +async function attachUserSession(req, _res, next) { + if (!userAuth) return next(); + const token = userToken(req); + req.userToken = token; + req.userSession = token ? await userAuth.verify(token) : null; + next(); +} +app.use(attachUserSession); +app.get("/auth/status", async (req, res) => { + await userAuthReady; + if (userAuth) { + 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); + return res.json({ + authenticated: true, + user: me, + mode: "user", + capabilities: capabilityState.capabilities, + grantedSkills: capabilityState.grantedSkills ?? [], + unrestricted: capabilityState.unrestricted + }); + } + 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: password2 } = req.body ?? {}; + if (!username || !password2) { + return res.status(400).json({ message: "\u7528\u6237\u540D\u548C\u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A" }); + } + const result2 = await userAuth.login({ username, password: password2, ip: req.ip }); + if (!result2.ok) { + if (result2.retryAfterMs > 0) { + res.set("Retry-After", String(Math.ceil(result2.retryAfterMs / 1e3))); + return res.status(429).json({ message: result2.message }); + } + return res.status(401).json({ message: result2.message }); + } + setUserLoginCookies(res, req, result2.token); + return res.json({ authenticated: true, user: result2.user, mode: "user" }); + } + if (!legacyAuth) { + return res.status(503).json({ message: "\u672A\u914D\u7F6E\u7528\u6237\u6570\u636E\u5E93\u6216\u8BBF\u95EE\u5BC6\u7801" }); + } + 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 / 1e3))); + return res.status(429).json({ message: "\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5" }); + } + return res.status(401).json({ message: "\u5BC6\u7801\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5" }); + } + res.set("Set-Cookie", sessionCookie(result.token, secure)); + return res.json({ authenticated: true, mode: "legacy" }); +}); +app.post("/auth/register", jsonBody, async (req, res) => { + await userAuthReady; + if (!userAuth) { + return res.status(503).json({ message: "\u672A\u542F\u7528\u7528\u6237\u6CE8\u518C" }); + } + const { username, password, displayName, email } = req.body ?? {}; + const result = await userAuth.register({ username, password, displayName, email }); + if (!result.ok) { + const status = result.message.includes("\u5DF2\u5B58\u5728") ? 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: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + } + 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: "\u5FAE\u4FE1 JS-SDK \u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const pageUrl = String(req.query?.url ?? "").split("#")[0]; + if (!pageUrl) { + return res.status(400).json({ message: "\u7F3A\u5C11 url" }); + } + try { + const publicHost = WECHAT_MP_CONFIG.publicBaseUrl ? new URL(WECHAT_MP_CONFIG.publicBaseUrl).host : null; + const target = new URL(pageUrl); + const requestHost = req.get("x-forwarded-host") || req.get("host") || ""; + if (publicHost && target.host !== publicHost && target.host !== requestHost) { + return res.status(400).json({ message: "url \u4E0D\u5C5E\u4E8E\u5F53\u524D H5 \u57DF\u540D" }); + } + const payload = await wechatMpService.createJsSdkSignature(pageUrl); + return res.json(payload); + } catch (err) { + const message = err instanceof Error ? err.message : "\u5FAE\u4FE1 JS-SDK \u7B7E\u540D\u5931\u8D25"; + console.warn("WeChat 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: "\u672A\u767B\u5F55" }); + 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: "\u516C\u4F17\u53F7 Agent \u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u516C\u4F17\u53F7 Agent \u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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 : "\u91CD\u5EFA\u516C\u4F17\u53F7 Agent \u8DEF\u7531\u5931\u8D25" + }); + } + }); +} +app.get("/auth/wechat/pending/:token", async (req, res) => { + await userAuthReady; + if (!userAuth) return res.status(503).json({ message: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + const pending = await userAuth.getWechatPendingBind(req.params.token); + if (!pending) return res.status(404).json({ message: "\u7ED1\u5B9A\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5FAE\u4FE1\u767B\u5F55" }); + 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: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + const pendingToken = typeof req.body?.pendingToken === "string" ? req.body.pendingToken : ""; + if (!pendingToken) return res.status(400).json({ message: "\u7F3A\u5C11\u7ED1\u5B9A\u4F1A\u8BDD" }); + 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: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + 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: "\u7F3A\u5C11\u7ED1\u5B9A\u4F1A\u8BDD" }); + if (!username || !password) { + return res.status(400).json({ message: "\u7528\u6237\u540D\u548C\u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A" }); + } + 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 / 1e3))); + } + 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: "\u5FAE\u4FE1\u767B\u5F55\u672A\u542F\u7528" }); + } + try { + const payload = await wechatOAuthService.startScanLogin(req); + return res.json(payload); + } catch (err) { + return res.status(503).json({ + message: err instanceof Error ? err.message : "\u5FAE\u4FE1\u626B\u7801\u767B\u5F55\u4E0D\u53EF\u7528" + }); + } +}); +app.get("/auth/wechat/scan/poll", async (req, res) => { + await userAuthReady; + if (!wechatOAuthService?.enabled) { + return res.status(503).json({ message: "\u5FAE\u4FE1\u767B\u5F55\u672A\u542F\u7528" }); + } + const state = typeof req.query?.state === "string" ? req.query.state : ""; + if (!state) return res.status(400).json({ message: "\u7F3A\u5C11\u626B\u7801\u72B6\u6001" }); + 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: "\u5FAE\u4FE1\u767B\u5F55\u672A\u542F\u7528" }); + } + 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: "\u8BF7\u5148\u767B\u5F55\u540E\u518D\u7ED1\u5B9A\u5FAE\u4FE1" }); + 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 : "\u5FAE\u4FE1\u6388\u6743\u5931\u8D25" }); + } +}); +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 || "\u5FAE\u4FE1\u767B\u5F55\u5931\u8D25")}` + ); + } + 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(`\u5FAE\u4FE1\u767B\u5F55

\u626B\u7801\u767B\u5F55\u6210\u529F\uFF0C\u8BF7\u8FD4\u56DE\u7535\u8111\u7EE7\u7EED\u64CD\u4F5C\u3002

`); + } + 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 : "\u5FAE\u4FE1\u767B\u5F55\u5931\u8D25"); + return res.redirect(302, `/?wechat_error=${message}`); + } +}); +app.get("/auth/me", async (req, res) => { + await userAuthReady; + if (!userAuth) return res.status(503).json({ message: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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, subscription }, + paths, + capabilities: capabilityState.capabilities, + grantedSkills: capabilityState.grantedSkills ?? [], + unrestricted: capabilityState.unrestricted + }); +}); +app.post("/auth/logout", async (req, res) => { + await userAuthReady; + const secure = isSecureRequest(req); + if (userAuth) { + await userAuth.revoke(userToken(req)); + clearUserLoginCookies(res, req); + } + if (legacyAuth) { + legacyAuth.revoke(legacySessionToken(req)); + res.set("Set-Cookie", clearSessionCookie(secure)); + } + res.status(204).end(); +}); +app.get("/auth/usage", async (req, res) => { + await userAuthReady; + if (!userAuth) return res.status(503).json({ message: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 }); + res.json({ records }); +}); +app.get("/auth/notifications", async (req, res) => { + await userAuthReady; + if (!userAuth || !scheduleService) { + return res.status(503).json({ message: "\u901A\u77E5\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u901A\u77E5\u5217\u8868\u52A0\u8F7D\u5931\u8D25" }); + } +}); +app.get("/auth/notifications/events", async (req, res) => { + await userAuthReady; + if (!userAuth || !scheduleService) { + return res.status(503).json({ message: "\u901A\u77E5\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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} +`); + res.write(`data: ${JSON.stringify(data)} + +`); + }; + 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() }); + }, 25e3); + 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: "\u901A\u77E5\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const ok = await scheduleService.markUserNotificationRead({ + userId: me.id, + notificationId: req.params.id + }); + if (!ok) return res.status(404).json({ message: "\u901A\u77E5\u4E0D\u5B58\u5728\u6216\u5DF2\u8BFB" }); + res.json({ ok: true }); +}); +app.post("/auth/notifications/read-all", async (req, res) => { + await userAuthReady; + if (!userAuth || !scheduleService) { + return res.status(503).json({ message: "\u901A\u77E5\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u901A\u77E5\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const ok = await scheduleService.deleteUserNotification({ + userId: me.id, + notificationId: req.params.id + }); + if (!ok) return res.status(404).json({ message: "\u901A\u77E5\u4E0D\u5B58\u5728" }); + res.status(204).end(); +}); +app.delete("/auth/notifications", async (req, res) => { + await userAuthReady; + if (!userAuth || !scheduleService) { + return res.status(503).json({ message: "\u901A\u77E5\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u672A\u542F\u7528\u8BA1\u8D39\u7CFB\u7EDF" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const sub = subscriptionService ? await subscriptionService.getActiveSubscription(me.id) : null; + const planCatalog = subscriptionService?._planCatalogService; + const plans = !sub && planCatalog ? await planCatalog.listPlans({ includeInactive: false }) : sub ? void 0 : 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: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u672A\u542F\u7528\u8BA2\u9605\u7CFB\u7EDF" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + if (me.status === "disabled") return res.status(403).json({ message: "\u8D26\u6237\u5DF2\u7981\u7528" }); + const { planType, autoRenew = false } = req.body ?? {}; + if (!planType || typeof planType !== "string") { + return res.status(400).json({ message: "\u8BF7\u9009\u62E9\u5957\u9910" }); + } + 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: "\u672A\u542F\u7528\u7A7A\u95F4\u7CFB\u7EDF" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u672A\u542F\u7528\u8BA2\u9605\u7CFB\u7EDF" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const { enabled } = req.body ?? {}; + if (typeof enabled !== "boolean") { + return res.status(400).json({ message: "\u8BF7\u4F20\u5165 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: "\u672A\u542F\u7528\u8BA1\u8D39\u7CFB\u7EDF" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + 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: "\u672A\u542F\u7528\u8BA1\u8D39\u7CFB\u7EDF" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const order = await rechargeService.getOrderForUser(me.id, req.params.orderId); + if (!order) return res.status(404).json({ message: "\u8BA2\u5355\u4E0D\u5B58\u5728" }); + 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 }); +}); +var wechatNotifyBody = express2.raw({ + type: ["application/json", "text/xml", "application/xml"], + limit: "64kb" +}); +var wechatMpBody = express2.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: "\u652F\u4ED8\u672A\u542F\u7528" }); + } + 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: "\u6210\u529F" }); + } catch (err) { + console.error("WeChat notify failed:", err); + const message = err instanceof Error ? err.message : "\u5904\u7406\u5931\u8D25"; + 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: (/* @__PURE__ */ 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"); + } + }); +} +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("; "); +} +var wikiApi = express2.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: "\u7528\u6237\u540D\u548C\u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A" }); + } + 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: "\u7528\u6237\u540D\u548C\u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A" }); + } + 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: "\u672A\u767B\u5F55" }); + 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: "\u9875\u9762\u4E0D\u5B58\u5728" }); + 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); +var api = express2.Router(); +api.use(jsonUnlessMultipart); +api.use(async (req, res, next) => { + await userAuthReady; + 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); + if (userAuth && tkmindProxy) { + if (req.userSession) { + const me2 = await userAuth.getMe(req.userToken); + if (me2) req.currentUser = me2; + } + if (plazaPublic) return next(); + if (!req.userSession) { + return res.status(401).json({ message: "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55" }); + } + const me = await userAuth.getMe(req.userToken); + if (!me) return res.status(401).json({ message: "\u767B\u5F55\u5DF2\u8FC7\u671F" }); + req.currentUser = me; + return next(); + } + if (legacyAuth?.verify(legacySessionToken(req))) return next(); + return res.status(401).json({ message: "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55" }); +}); +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) { + 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 : "\u4EE3\u7406\u5931\u8D25" }); + } + } + return next(); +}); +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", "\u7528\u6237\u7A7A\u95F4\u4E0D\u5B58\u5728"); + return sendData(res, req, space); +}); +api.get("/mindspace/v1/space/quota", async (req, res) => { + if (!mindSpace) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + const quota = await mindSpace.getQuota(req.currentUser.id); + if (!quota) return res.status(404).json({ message: "\u7528\u6237\u7A7A\u95F4\u4E0D\u5B58\u5728" }); + return res.json({ data: quota }); +}); +api.get("/mindspace/v1/space/categories", async (req, res) => { + if (!mindSpace) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + const categories = await mindSpace.listCategories(req.currentUser.id); + if (!categories) return res.status(404).json({ message: "\u7528\u6237\u7A7A\u95F4\u4E0D\u5B58\u5728" }); + 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(path23, method) { + if (!path23.startsWith("/plaza/v1/")) return false; + if (method === "POST" && path23 === "/plaza/v1/events") return true; + if (method !== "GET") return false; + return path23 === "/plaza/v1/feed" || path23 === "/plaza/v1/categories" || path23 === "/plaza/v1/seo/sitemap" || /^\/plaza\/v1\/posts\/[^/]+$/.test(path23) || /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path23) || /^\/plaza\/v1\/users\/[^/]+$/.test(path23) || /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path23); +} +var PLAZA_SID_COOKIE = "plaza_sid"; +function resolvePlazaSessionId(req, res) { + const cookies = parseCookies(req.get("cookie")); + let sessionId = cookies[PLAZA_SID_COOKIE]; + if (!sessionId) { + sessionId = crypto29.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 \u672A\u542F\u7528"); + return false; + } + return true; +} +function plazaRouteError(res, req, error) { + const status = mapPlazaError(error); + const code = error?.code ?? "internal_error"; + const message = error instanceof Error ? error.message : "Plaza \u8BF7\u6C42\u5931\u8D25"; + return sendError(res, req, status, code, message, error?.details); +} +function ensurePlazaEnabled(res, req) { + if (!plazaPosts) { + sendError(res, req, 503, "plaza_unavailable", "Plaza \u672A\u542F\u7528"); + 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, + 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 \u670D\u52A1\u5F02\u5E38" : error?.message || "MindSpace \u670D\u52A1\u5F02\u5E38"; + return sendError(res, req, status, code, message, error?.details); +} +function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) { + try { + assertMindSpaceRoute(msFlags, upload ? "upload" : agent ? "agent" : void 0); + 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", "\u5185\u90E8 Agent \u51ED\u636E\u65E0\u6548"); + 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); + } +}); +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 fs23.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.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 + }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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.delete("/mindspace/v1/uploads/:uploadId", async (req, res) => { + if (!mindSpaceAssets) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + try { + const assets = await mindSpaceAssets.listAssets(req.currentUser.id, { + categoryId: typeof req.query.category_id === "string" ? req.query.category_id : void 0, + categoryCode: typeof req.query.category_code === "string" ? req.query.category_code : void 0 + }); + return res.json({ data: assets, page: { next_cursor: null, has_more: false } }); + } catch (error) { + return mindSpaceError(res, req, error); + } +}); +api.get("/mindspace/v1/assets/:assetId/download", async (req, res) => { + if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; + try { + const { asset, path: assetPath } = await mindSpaceAssets.readAsset( + req.currentUser.id, + req.params.assetId + ); + await mindSpaceAudit?.write({ + userId: req.currentUser.id, + action: "asset.download", + objectType: "asset", + objectId: req.params.assetId, + ip: req.ip, + riskLevel: asset.riskLevel + }); + 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(req.params.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, + 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 \u672A\u542F\u7528" }); + 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 + ); + const content = await fs23.promises.readFile(assetPath, "utf8"); + const contentFormat = asset.mimeType === "text/html" ? "html" : "markdown"; + 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 + } + } + ); + 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(userId, sessionId, messageId) { + if (!sessionId || !messageId) { + throw Object.assign(new Error("\u7F3A\u5C11\u6765\u6E90\u4F1A\u8BDD\u6216\u6D88\u606F"), { + code: "invalid_page_input" + }); + } + if (!await userAuth.ownsSession(userId, sessionId)) { + throw Object.assign(new Error("\u6765\u6E90\u4F1A\u8BDD\u4E0D\u5B58\u5728"), { code: "source_message_not_found" }); + } + const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, { + method: "GET" + }); + if (!upstream.ok) { + throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u6765\u6E90\u4F1A\u8BDD"), { code: "source_message_not_found" }); + } + const session = await upstream.json(); + const message = (session.conversation ?? []).find((item) => item.id === messageId); + if (!message) { + throw Object.assign(new Error("\u6765\u6E90\u6D88\u606F\u4E0D\u5B58\u5728"), { code: "source_message_not_found" }); + } + const content = messageText(message); + if (message.role !== "assistant" || !message.metadata?.userVisible || !content) { + throw Object.assign(new Error("\u53EA\u6709\u53EF\u89C1\u7684 AI \u6587\u672C\u6D88\u606F\u53EF\u4EE5\u4FDD\u5B58\u4E3A\u9875\u9762"), { + code: "invalid_source_message" + }); + } + return { session, message, content }; +} +var SAVE_TARGET_CATEGORIES = /* @__PURE__ */ new Set(["draft", "oa", "private", "public"]); +function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) { + if (categoryCode !== "private") return; + if (!privacyScan.allowed) { + throw Object.assign(new Error("\u5185\u5BB9\u542B\u963B\u65AD\u7EA7\u654F\u611F\u4FE1\u606F\uFF0C\u4E0D\u80FD\u4FDD\u5B58\u5230\u79C1\u4EBA\u533A"), { + 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("\u4FDD\u5B58\u5230\u79C1\u4EBA\u533A\u524D\u9700\u786E\u8BA4\u654F\u611F\u4FE1\u606F\u63D0\u793A"), { + code: "private_ack_required", + details: { findings: missing } + }); + } +} +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.id, sessionId, messageId); + const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ + content: source.content, + userId: user.id, + username: user.username, + h5Root, + selectedLinkIndex + }); + 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 + }; +} +api.get("/mindspace/v1/pages/chat-save-preview", async (req, res) => { + if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + try { + const bundle = await resolveChatSaveBundle(req.currentUser, __dirname5, req.query); + if (!bundle.resolvedHtml) { + throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { 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 \u672A\u542F\u7528" }); + try { + const bundle = await resolveChatSaveBundle(req.currentUser, __dirname5, req.query); + if (!bundle.resolvedHtml) { + throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { code: "static_page_not_found" }); + } + const publishDir = resolvePublishDir(__dirname5, 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 svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, { + title, + subtitle, + contentBaseDir: path22.dirname(bundle.resolvedHtml.absolute), + 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 \u672A\u542F\u7528" }); + try { + const bundle = await resolveChatSaveBundle(req.currentUser, __dirname5, req.body); + const { + source, + analysis, + resolvedHtml, + previewTitle, + previewSummary, + previewFrameUrl, + thumbnailUrl, + localPreviewUrl, + localThumbnailUrl + } = bundle; + let thumbnailReady = false; + if (resolvedHtml?.content && resolvedHtml.relativePath) { + const publishDir = resolvePublishDir(__dirname5, 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; + } + } + 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) + }); + } catch (error) { + return mindSpaceError(res, req, error); + } +}); +api.post("/mindspace/v1/pages/save-from-chat", async (req, res) => { + if (!mindSpacePages || !mindSpaceAssets) { + return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + } + try { + const categoryCode = String(req.body?.category_code ?? "draft"); + if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) { + throw Object.assign(new Error("\u65E0\u6548\u7684\u4FDD\u5B58\u76EE\u6807"), { code: "invalid_category_code" }); + } + const source = await resolveOwnedAssistantMessage( + req.currentUser.id, + req.body?.session_id, + req.body?.message_id + ); + const analysis = analyzeChatMessageForSave({ + content: source.content, + userId: req.currentUser.id, + username: req.currentUser.username, + h5Root: __dirname5, + 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 + }; + let resolvedHtml = null; + if (analysis.contentMode === "static_html") { + 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; + let filename; + let displayName = req.body?.title; + if (analysis.contentMode === "static_html") { + if (!resolvedHtml) { + throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { 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("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { 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 = resolvePublishDir(__dirname5, req.currentUser); + await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { + title: pageInput.title, + subtitle: pageInput.summary + }).catch(() => { + }); + } + const 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 \u672A\u542F\u7528" }); + 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" : void 0, + 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 \u672A\u542F\u7528" }); + try { + const pages = await mindSpacePages.listPages(req.currentUser.id, { + status: typeof req.query.status === "string" ? req.query.status : void 0 + }); + return res.json({ data: pages, page: { next_cursor: null, has_more: false } }); + } 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 \u672A\u542F\u7528" }); + 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 result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId); + 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, + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + } + try { + const sessionId = String(req.body?.session_id ?? "").trim(); + if (!sessionId) { + return sendError(res, req, 400, "invalid_request", "\u7F3A\u5C11 session_id"); + } + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return sendError(res, req, 403, "forbidden", "\u65E0\u6743\u7ED1\u5B9A\u8BE5 Agent \u4F1A\u8BDD"); + } + 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 \u672A\u542F\u7528" }); + } + try { + const parentSessionId = String(req.body?.parent_session_id ?? "").trim(); + if (!parentSessionId) { + return sendError(res, req, 400, "invalid_request", "\u7F3A\u5C11 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 \u672A\u542F\u7528" }); + } + try { + const sessionId = String(req.body?.session_id ?? "").trim(); + if (!sessionId) { + return sendError(res, req, 400, "invalid_request", "\u7F3A\u5C11 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 \u672A\u542F\u7528" }); + } + 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 \u672A\u542F\u7528" }); + } + 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.get("/mindspace/v1/pages/:pageId/thumbnail", async (req, res) => { + if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u670D\u52A1\u672A\u5C31\u7EEA"), { 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.get("/mindspace/v1/pages/:pageId/preview", async (req, res) => { + if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 + } + ); + 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/offline", async (req, res) => { + if (!mindSpacePublications) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528" }); + 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 \u672A\u542F\u7528"); + 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 \u672A\u542F\u7528"); + 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 \u672A\u542F\u7528"); + if (!req.currentUser) return sendError(res, req, 401, "unauthorized", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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 \u672A\u542F\u7528"); + if (!req.currentUser) return sendError(res, req, 401, "unauthorized", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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 \u672A\u542F\u7528"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + } + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + } + 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", "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"); + } + 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) => { + await userAuthReady; + if (!userAuth || !llmProviderService || !tkmindProxy) { + return res.status(503).json({ message: "\u672A\u542F\u7528 LLM \u914D\u7F6E" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const sessionId = String(req.body?.session_id ?? "").trim(); + if (!sessionId) return res.status(400).json({ message: "\u7F3A\u5C11 session_id" }); + const owns = await userAuth.ownsSession(me.id, sessionId); + if (!owns) return res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + 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 : "\u5207\u6362\u672C\u5730 LLM \u5931\u8D25" + }); + } +}); +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/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); +}); +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: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + } + 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) { + const cachedGooseSession = { + ...snapshot.session, + // Embed only userVisible messages so getSession callers still work. + conversation: snapshot.messages + }; + return res.json(cachedGooseSession); + } + } + } + } catch { + } + 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(); + 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 : "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25" }); + } +}); +api.delete("/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: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + } + try { + 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 || "\u5220\u9664\u4F1A\u8BDD\u5931\u8D25"); + } + await userAuth.unregisterAgentSession(req.currentUser.id, sessionId); + void sessionSnapshotService?.remove(sessionId).catch(() => { + }); + return res.status(204).end(); + } catch (err) { + return res.status(500).json({ message: err instanceof Error ? err.message : "\u5220\u9664\u4F1A\u8BDD\u5931\u8D25" }); + } +}); +api.get("/sessions/:sessionId/events", 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: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + } + 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) => { + await userAuthReady; + if (!userAuth || !tkmindProxy) return next(); + if (isNativeH5ApiPath(req.path)) { + return sendError(res, req, 404, "not_found", `\u63A5\u53E3\u4E0D\u5B58\u5728\uFF1A${req.method} ${req.path}`); + } + const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/); + if (sessionMatch) { + const sessionId = sessionMatch[1]; + if (req.path.endsWith("/events") && req.method === "GET") { + return next(); + } + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + } + if (req.path.endsWith("/reply") && req.method === "POST") { + const gate = await userAuth.canUseChat(req.currentUser.id); + if (!gate.ok) { + return res.status(402).json({ + message: gate.message, + code: gate.code, + balanceCents: gate.balanceCents, + minRechargeCents: gate.minRechargeCents, + 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 ? `\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${err.message}` : "\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25" + }); + } + if (llmProviderService) { + try { + await tkmindProxy.applySessionLlmProvider(sessionId); + } catch (err) { + console.warn( + "LLM provider sync before reply skipped:", + err instanceof Error ? err.message : err + ); + } + } + } + } + if (req.method === "POST" && req.path === "/agent/resume" && req.body?.session_id) { + const owns = await userAuth.ownsSession(req.currentUser.id, req.body.session_id); + if (!owns) { + return res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + } + } + 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: "\u5DE5\u4F5C\u76EE\u5F55\u4E0D\u5728\u6388\u6743\u8303\u56F4\u5185" }); + } + } + 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); +function publishedPageCsp(html, { embed = false, raw = false } = {}) { + const isFullHtml = /^\s*]/i.test(html); + if (embed && isFullHtml) { + return publishedPageCspForEmbed(true); + } + if (raw && isFullHtml) { + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'"; + } + if (isFullHtml) { + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'none'"; + } + return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"; +} +function appendQueryParam(url, key, value) { + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; +} +function resolveRequestOrigin(req) { + const host = (req.headers["x-forwarded-host"] || req.headers.host || "").toString().split(",")[0].trim(); + if (!host) return ""; + const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host); + const fwdProto = (req.headers["x-forwarded-proto"] || "").toString().split(",")[0].trim(); + const proto = isLocalHost ? fwdProto || req.protocol || "http" : "https"; + return `${proto}://${host}`; +} +function detectPublishedPageTitle(html) { + const match = String(html ?? "").match(/]*>([^<]+)<\/title>/i); + return match?.[1]?.replace(/\s+/g, " ").trim() || "MindSpace \u9875\u9762"; +} +function publishedPageShellHtml({ iframeUrl, shareUrl, title }) { + const iframeSrc = escapePublicHtml(iframeUrl); + const safeShareUrl = escapePublicHtml(shareUrl); + const safeTitle = escapePublicHtml(title); + const serializedShareUrl = JSON.stringify(shareUrl).replace(/ + + + + + + ${safeTitle} + + + +
+ + +
+ + + + +`; +} +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); + } + const isFullHtml = /^\s*]/i.test(html); + const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== "password"; + if (canWrapWithShell) { + const title = detectPublishedPageTitle(html); + const rawUrl = appendQueryParam(req.originalUrl || req.url || "", "view", "raw"); + 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'; 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( + publishedPageShellHtml({ + iframeUrl: rawUrl, + shareUrl: req.originalUrl ? new URL(req.originalUrl, resolveRequestOrigin(req) || "http://localhost").toString() : "", + title + }) + ); + } + res.set("Content-Type", "text/html; charset=utf-8"); + res.set("Content-Security-Policy", publishedPageCsp(html, { embed, raw })); + res.set( + "Cache-Control", + result.publication.accessMode === "public" ? "public, max-age=60" : "private, no-store" + ); + return res.send(html); +} +function passwordGateHtml(action) { + return ` + + + + + + \u53D7\u4FDD\u62A4\u9875\u9762 + + + +
+

\u6B64\u9875\u9762\u53D7\u5BC6\u7801\u4FDD\u62A4

+

\u8BF7\u8F93\u5165\u53D1\u5E03\u8005\u63D0\u4F9B\u7684\u8BBF\u95EE\u5BC6\u7801\u3002\u5BC6\u7801\u4E0D\u4F1A\u5199\u5165\u94FE\u63A5\u6216\u6D4F\u89C8\u5668\u65E5\u5FD7\u3002

+ + +
+ +`; +} +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)} \u6B21\u6D4F\u89C8 +
+

${escapePublicHtml(page.title)}

+

${escapePublicHtml(page.summary || "\u6682\u65E0\u6458\u8981")}

+
+ ${new Date(page.publishedAt).toLocaleDateString("zh-CN")} + \u6253\u5F00\u9875\u9762 +
+
` + ).join(""); + return ` + + + + + + ${escapePublicHtml(data.owner.displayName)} \xB7 MindSpace + + + +
+
+

MindSpace Public

+

${escapePublicHtml(data.owner.displayName)}

+

\u8FD9\u91CC\u5C55\u793A ${escapePublicHtml(data.owner.displayName)} \u5F53\u524D\u516C\u5F00\u53D1\u5E03\u4E14\u5728\u7EBF\u7684 MindSpace \u9875\u9762\u3002

+
+ ${Number(data.pageCount)} \u4E2A\u516C\u5F00\u9875\u9762 + ${Number(data.totalViews)} \u6B21\u7D2F\u8BA1\u6D4F\u89C8 + /u/${escapePublicHtml(data.owner.slug)} +
+
+ ${data.pages.length ? `
${cards}
` : '
\u8FD9\u4E2A\u4E3B\u9875\u8FD8\u6CA1\u6709\u516C\u5F00\u9875\u9762\u3002\u7A0D\u540E\u518D\u6765\u770B\u770B\uFF0C\u6216\u8005\u76F4\u63A5\u8BBF\u95EE\u4F5C\u8005\u5206\u4EAB\u7ED9\u4F60\u7684\u4E13\u5C5E\u94FE\u63A5\u3002
'} +
+ +`; +} +async function resolvePublishedRoute(req, res, password = null) { + await userAuthReady; + if (!mindSpacePublications) return res.status(503).send("MindSpace \u672A\u542F\u7528"); + 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") + } + ); + return sendPublishedPage(req, res, result, { + embed: isPlazaEmbedRequest(req.query), + raw: String(req.query.view ?? "").toLowerCase() === "raw" + }); + } 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("\u8BF7\u5148\u767B\u5F55 TKMind \u540E\u518D\u8BBF\u95EE\u6B64\u9875\u9762"); + } + if (error?.code === "publication_not_found") return res.status(404).send("\u9875\u9762\u4E0D\u5B58\u5728\u6216\u5DF2\u4E0B\u7EBF"); + return res.status(500).send("\u9875\u9762\u52A0\u8F7D\u5931\u8D25"); + } +} +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 \u672A\u542F\u7528"); + 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("\u7F29\u7565\u56FE\u4E0D\u5B58\u5728"); + 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("\u7F29\u7565\u56FE\u52A0\u8F7D\u5931\u8D25"); + } +}); +app.get("/u/:ownerSlug/pages/:urlSlug", async (req, res) => { + return resolvePublishedRoute(req, res); +}); +app.get("/u/:ownerSlug", async (req, res) => { + await userAuthReady; + if (!mindSpacePublications) return res.status(503).send("MindSpace \u672A\u542F\u7528"); + 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("\u4E3B\u9875\u4E0D\u5B58\u5728"); + return res.status(500).send("\u4E3B\u9875\u52A0\u8F7D\u5931\u8D25"); + } +}); +app.post( + "/u/:ownerSlug/pages/:urlSlug", + express2.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 \u672A\u542F\u7528"); + try { + const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null; + return 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("\u9875\u9762\u4E0D\u5B58\u5728\u6216\u5DF2\u4E0B\u7EBF"); + return res.status(500).send("\u9875\u9762\u52A0\u8F7D\u5931\u8D25"); + } +}); +var USERNAME_SLUG = /^[a-z0-9_]{2,32}$/; +async function resolvePublishDirKey(segment) { + const lower = String(segment ?? "").trim().toLowerCase(); + if (!lower) return null; + if (PUBLISH_KEY_UUID.test(lower)) return lower; + if (USERNAME_SLUG.test(lower)) { + if (authPool) { + const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [lower]); + if (rows[0]?.id) return String(rows[0].id).toLowerCase(); + } + return lower; + } + return null; +} +function sendPublishFile(req, res, filePath) { + if (!filePath.toLowerCase().endsWith(".html")) { + res.sendFile(filePath, (err) => { + if (err && !res.headersSent) res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); + }); + return; + } + let html; + try { + html = fs23.readFileSync(filePath, "utf8"); + } catch { + res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); + 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(); + const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host); + const fwdProto = (req.headers["x-forwarded-proto"] || "").toString().split(",")[0].trim(); + const proto = isLocalHost ? fwdProto || req.protocol || "http" : "https"; + const origin = host ? `${proto}://${host}` : ""; + const cleanPath = req.originalUrl.split("?")[0].split("#")[0]; + const servedName = path22.basename(filePath); + const urlLast = decodeURIComponent(cleanPath.split("/").filter(Boolean).pop() ?? ""); + const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase(); + const pageUrl = origin ? `${origin}${isImplicitIndex && !cleanPath.endsWith("/") ? `${cleanPath}/` : cleanPath}` : ""; + const pageDirUrl = !origin ? "" : isImplicitIndex ? `${origin}${cleanPath.endsWith("/") ? cleanPath : `${cleanPath}/`}` : `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf("/") + 1)}`; + let fallbackImageUrl = ""; + const svgSibling = filePath.replace(/\.[^./]+$/, ".thumbnail.svg"); + if (pageDirUrl && fs23.existsSync(svgSibling)) { + const pngName = path22.basename(thumbnailPngPathForSvg(svgSibling)); + fallbackImageUrl = `${pageDirUrl}${pngName}`; + } + try { + html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl }); + } catch { + } + res.set("Content-Type", "text/html; charset=utf-8"); + res.send(html); +} +var 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 = path22.resolve(targetDir, PUBLIC_ZONE_DIR, filename); + if (!destination.startsWith(`${resolvedRoot}${path22.sep}`)) return null; + const candidates = [ + path22.resolve(__dirname5, filename), + path22.resolve(__dirname5, PUBLIC_ZONE_DIR, filename) + ]; + for (const candidate of candidates) { + if (candidate === destination) continue; + if (!candidate.startsWith(`${__dirname5}${path22.sep}`)) continue; + if (!fs23.existsSync(candidate) || !fs23.statSync(candidate).isFile()) continue; + fs23.mkdirSync(path22.dirname(destination), { recursive: true }); + fs23.copyFileSync(candidate, destination); + await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => { + }); + console.warn( + `[MindSpace] recovered misplaced public HTML ${path22.relative(__dirname5, candidate)} -> ${path22.relative(__dirname5, destination)}` + ); + return destination; + } + return null; +} +async function serveUserPublishFile(req, res, next) { + const parts = req.path.split("/").filter(Boolean); + if (parts.length < 1) { + res.status(404).json({ message: "\u672A\u627E\u5230\u9875\u9762" }); + return; + } + const dirKey = await resolvePublishDirKey(parts[0]); + if (!dirKey) { + res.status(404).json({ message: "\u672A\u627E\u5230\u9875\u9762" }); + return; + } + if (parts[0].toLowerCase() !== dirKey && USERNAME_SLUG.test(parts[0])) { + const rest2 = parts.slice(1).map(encodeURIComponent).join("/"); + const target = `/${PUBLISH_ROOT_DIR}/${dirKey}${rest2 ? `/${rest2}` : "/"}`; + res.redirect(301, target); + return; + } + const [username, ...rest] = [dirKey, ...parts.slice(1)]; + const targetDir = path22.join(__dirname5, PUBLISH_ROOT_DIR, username); + const resolvedRoot = path22.resolve(targetDir); + const filePath = path22.join(targetDir, ...rest); + const resolvedPath = path22.resolve(filePath); + if (!resolvedPath.startsWith(`${resolvedRoot}${path22.sep}`) && resolvedPath !== resolvedRoot) { + res.status(403).json({ message: "\u7981\u6B62\u8BBF\u95EE" }); + return; + } + if (!fs23.existsSync(targetDir)) { + res.status(404).json({ message: "\u7528\u6237\u4E0D\u5B58\u5728" }); + return; + } + if (/\.thumbnail\.png$/i.test(resolvedPath)) { + const svgSibling = resolvedPath.replace(/\.png$/i, ".svg"); + if (fs23.existsSync(svgSibling)) { + const pngPath = ensureThumbnailPng(svgSibling); + if (pngPath && fs23.existsSync(pngPath)) { + res.set("Cache-Control", "public, max-age=300"); + res.sendFile(pngPath); + return; + } + } + } + if (!fs23.existsSync(resolvedPath)) { + if (rest.length === 1 && rest[0].toLowerCase().endsWith(".html")) { + const publicFallback = path22.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); + if (publicFallback.startsWith(`${resolvedRoot}${path22.sep}`) && fs23.existsSync(publicFallback) && fs23.statSync(publicFallback).isFile()) { + 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); + return; + } + if (fs23.statSync(resolvedPath).isDirectory()) { + const indexPath = path22.join(resolvedPath, "index.html"); + if (fs23.existsSync(indexPath)) { + sendPublishFile(req, res, indexPath); + return; + } + res.status(404).json({ message: "\u76EE\u5F55\u4E2D\u6CA1\u6709 index.html" }); + return; + } + sendPublishFile(req, res, resolvedPath); +} +app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => { + await userAuthReady; + return serveUserPublishFile(req, res, next); +}); +app.use("/temp", (req, res) => { + res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`); +}); +app.use("/user", async (req, res, next) => { + await userAuthReady; + const parts = req.path.split("/").filter(Boolean); + if (parts.length < 1) return next(); + const [username, ...rest] = parts; + const targetDir = path22.join(USERS_ROOT, username); + const filePath = path22.join(targetDir, ...rest); + const resolvedRoot = path22.resolve(targetDir); + const resolvedPath = path22.resolve(filePath); + if (!resolvedPath.startsWith(resolvedRoot + path22.sep) && resolvedPath !== resolvedRoot) { + return res.status(403).json({ message: "\u7981\u6B62\u8BBF\u95EE" }); + } + if (!fs23.existsSync(targetDir)) { + return res.status(404).json({ message: "\u7528\u6237\u4E0D\u5B58\u5728" }); + } + if (!fs23.existsSync(resolvedPath)) { + return res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); + } + if (fs23.statSync(resolvedPath).isDirectory()) { + const indexPath = path22.join(resolvedPath, "index.html"); + if (fs23.existsSync(indexPath)) { + return res.sendFile(indexPath); + } + return res.status(404).json({ message: "\u76EE\u5F55\u4E2D\u6CA1\u6709 index.html" }); + } + res.sendFile(resolvedPath, (err) => { + if (err) res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); + }); +}); +app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => { + res.sendFile(path22.join(__dirname5, 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", + express2.static(path22.join(__dirname5, "public/plaza-covers"), { maxAge: "7d" }) +); +app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => { + const fileName = path22.basename(req.path); + const filePath = path22.join(__dirname5, "public", fileName); + if (!fs23.existsSync(filePath)) return res.status(404).end(); + res.type("text/plain").sendFile(filePath); +}); +app.use(express2.static(path22.join(__dirname5, "dist"), { index: "index.html" })); +app.get("*", (_req, res) => { + res.sendFile(path22.join(__dirname5, "dist", "index.html")); +}); +userAuthReady.then((enabled) => { + app.listen(PORT, "127.0.0.1", () => { + console.log(`TKMind H5 @ http://127.0.0.1:${PORT}`); + console.log(`Proxy -> ${API_TARGETS.join(", ")}`); + console.log(`Auth -> ${enabled ? "multi-user (MySQL)" : legacyAuth ? "legacy password" : "disabled"}`); + console.log(`Wiki @ http://127.0.0.1:${PORT}/${PUBLISH_ROOT_DIR}/wiki`); + }); +}); diff --git a/public/plaza-covers/business-1bt5ajr.jpg b/public/plaza-covers/business-1bt5ajr.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-1bt5ajr.jpg differ diff --git a/public/plaza-covers/business-1dpghh8.jpg b/public/plaza-covers/business-1dpghh8.jpg new file mode 100644 index 0000000..76d994a Binary files /dev/null and b/public/plaza-covers/business-1dpghh8.jpg differ diff --git a/public/plaza-covers/business-30-84q88u.jpg b/public/plaza-covers/business-30-84q88u.jpg new file mode 100644 index 0000000..a22930d Binary files /dev/null and b/public/plaza-covers/business-30-84q88u.jpg differ diff --git a/public/plaza-covers/business-coffee-shop.jpg b/public/plaza-covers/business-coffee-shop.jpg new file mode 100644 index 0000000..9117550 Binary files /dev/null and b/public/plaza-covers/business-coffee-shop.jpg differ diff --git a/public/plaza-covers/business-e09bef.jpg b/public/plaza-covers/business-e09bef.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-e09bef.jpg differ diff --git a/public/plaza-covers/business-nfc9ph.jpg b/public/plaza-covers/business-nfc9ph.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-nfc9ph.jpg differ diff --git a/public/plaza-covers/business-pilates.jpg b/public/plaza-covers/business-pilates.jpg new file mode 100644 index 0000000..e7ad1cc Binary files /dev/null and b/public/plaza-covers/business-pilates.jpg differ diff --git a/public/plaza-covers/business-sop-33nw2t.jpg b/public/plaza-covers/business-sop-33nw2t.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-sop-33nw2t.jpg differ diff --git a/public/plaza-covers/business-zfht2z.jpg b/public/plaza-covers/business-zfht2z.jpg new file mode 100644 index 0000000..2da05d3 Binary files /dev/null and b/public/plaza-covers/business-zfht2z.jpg differ diff --git a/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg differ diff --git a/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg differ diff --git a/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg new file mode 100644 index 0000000..2da05d3 Binary files /dev/null and b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg differ diff --git a/public/plaza-covers/business-独立书店文创周边选品策略.jpg b/public/plaza-covers/business-独立书店文创周边选品策略.jpg new file mode 100644 index 0000000..76d994a Binary files /dev/null and b/public/plaza-covers/business-独立书店文创周边选品策略.jpg differ diff --git a/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg differ diff --git a/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg new file mode 100644 index 0000000..a22930d Binary files /dev/null and b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg differ diff --git a/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg differ diff --git a/public/plaza-covers/creative-17bb2q5.jpg b/public/plaza-covers/creative-17bb2q5.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-17bb2q5.jpg differ diff --git a/public/plaza-covers/creative-1a69un3.jpg b/public/plaza-covers/creative-1a69un3.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-1a69un3.jpg differ diff --git a/public/plaza-covers/creative-1wuxajv.jpg b/public/plaza-covers/creative-1wuxajv.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-1wuxajv.jpg differ diff --git a/public/plaza-covers/creative-ai-1m59cjr.jpg b/public/plaza-covers/creative-ai-1m59cjr.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-ai-1m59cjr.jpg differ diff --git a/public/plaza-covers/creative-ceramics-1o5zmj5.jpg b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg new file mode 100644 index 0000000..495339d Binary files /dev/null and b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg differ diff --git a/public/plaza-covers/creative-cyber-city.jpg b/public/plaza-covers/creative-cyber-city.jpg new file mode 100644 index 0000000..980e792 Binary files /dev/null and b/public/plaza-covers/creative-cyber-city.jpg differ diff --git a/public/plaza-covers/creative-music-ep.jpg b/public/plaza-covers/creative-music-ep.jpg new file mode 100644 index 0000000..980e792 Binary files /dev/null and b/public/plaza-covers/creative-music-ep.jpg differ diff --git a/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg differ diff --git a/public/plaza-covers/creative-vis-tkrnir.jpg b/public/plaza-covers/creative-vis-tkrnir.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-vis-tkrnir.jpg differ diff --git a/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg differ diff --git a/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg differ diff --git a/public/plaza-covers/creative-复古胶片人像调色预设包.jpg b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg differ diff --git a/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg new file mode 100644 index 0000000..495339d Binary files /dev/null and b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg differ diff --git a/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg differ diff --git a/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg differ diff --git a/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg differ diff --git a/public/plaza-covers/data-analysis-1293jxp.jpg b/public/plaza-covers/data-analysis-1293jxp.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-1293jxp.jpg differ diff --git a/public/plaza-covers/data-analysis-a-b-1slrtg.jpg b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg differ diff --git a/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg differ diff --git a/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg differ diff --git a/public/plaza-covers/data-analysis-cohort.jpg b/public/plaza-covers/data-analysis-cohort.jpg new file mode 100644 index 0000000..b5db113 Binary files /dev/null and b/public/plaza-covers/data-analysis-cohort.jpg differ diff --git a/public/plaza-covers/data-analysis-g6tcgg.jpg b/public/plaza-covers/data-analysis-g6tcgg.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-g6tcgg.jpg differ diff --git a/public/plaza-covers/data-analysis-gmv.jpg b/public/plaza-covers/data-analysis-gmv.jpg new file mode 100644 index 0000000..5b146d7 Binary files /dev/null and b/public/plaza-covers/data-analysis-gmv.jpg differ diff --git a/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg new file mode 100644 index 0000000..a39faf3 Binary files /dev/null and b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg differ diff --git a/public/plaza-covers/data-analysis-sql-9sel2d.jpg b/public/plaza-covers/data-analysis-sql-9sel2d.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-sql-9sel2d.jpg differ diff --git a/public/plaza-covers/data-analysis-ztijjg.jpg b/public/plaza-covers/data-analysis-ztijjg.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-ztijjg.jpg differ diff --git a/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg differ diff --git a/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg differ diff --git a/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg differ diff --git a/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg differ diff --git a/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg new file mode 100644 index 0000000..a39faf3 Binary files /dev/null and b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg differ diff --git a/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg differ diff --git a/public/plaza-covers/lifestyle-100-40-l2vjup.jpg b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg new file mode 100644 index 0000000..e2e61e9 Binary files /dev/null and b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg differ diff --git a/public/plaza-covers/lifestyle-1o3s87z.jpg b/public/plaza-covers/lifestyle-1o3s87z.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-1o3s87z.jpg differ diff --git a/public/plaza-covers/lifestyle-1wfbcos.jpg b/public/plaza-covers/lifestyle-1wfbcos.jpg new file mode 100644 index 0000000..f73669e Binary files /dev/null and b/public/plaza-covers/lifestyle-1wfbcos.jpg differ diff --git a/public/plaza-covers/lifestyle-30-173irnz.jpg b/public/plaza-covers/lifestyle-30-173irnz.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-30-173irnz.jpg differ diff --git a/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg differ diff --git a/public/plaza-covers/lifestyle-7-1cwt66m.jpg b/public/plaza-covers/lifestyle-7-1cwt66m.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-7-1cwt66m.jpg differ diff --git a/public/plaza-covers/lifestyle-desk.jpg b/public/plaza-covers/lifestyle-desk.jpg new file mode 100644 index 0000000..8599dcc Binary files /dev/null and b/public/plaza-covers/lifestyle-desk.jpg differ diff --git a/public/plaza-covers/lifestyle-m33c9i.jpg b/public/plaza-covers/lifestyle-m33c9i.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-m33c9i.jpg differ diff --git a/public/plaza-covers/lifestyle-o90i4e.jpg b/public/plaza-covers/lifestyle-o90i4e.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-o90i4e.jpg differ diff --git a/public/plaza-covers/lifestyle-writing.jpg b/public/plaza-covers/lifestyle-writing.jpg new file mode 100644 index 0000000..8599dcc Binary files /dev/null and b/public/plaza-covers/lifestyle-writing.jpg differ diff --git a/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg new file mode 100644 index 0000000..f73669e Binary files /dev/null and b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg differ diff --git a/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg differ diff --git a/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg differ diff --git a/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg differ diff --git a/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg new file mode 100644 index 0000000..e2e61e9 Binary files /dev/null and b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg differ diff --git a/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg differ diff --git a/public/plaza-covers/other-0-1000-35irqz.jpg b/public/plaza-covers/other-0-1000-35irqz.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-0-1000-35irqz.jpg differ diff --git a/public/plaza-covers/other-176h7rn.jpg b/public/plaza-covers/other-176h7rn.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-176h7rn.jpg differ diff --git a/public/plaza-covers/other-187ouof.jpg b/public/plaza-covers/other-187ouof.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-187ouof.jpg differ diff --git a/public/plaza-covers/other-1nd2tz4.jpg b/public/plaza-covers/other-1nd2tz4.jpg new file mode 100644 index 0000000..5dca65c Binary files /dev/null and b/public/plaza-covers/other-1nd2tz4.jpg differ diff --git a/public/plaza-covers/other-20-1de41cz.jpg b/public/plaza-covers/other-20-1de41cz.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-20-1de41cz.jpg differ diff --git a/public/plaza-covers/other-opensource.jpg b/public/plaza-covers/other-opensource.jpg new file mode 100644 index 0000000..21dfb28 Binary files /dev/null and b/public/plaza-covers/other-opensource.jpg differ diff --git a/public/plaza-covers/other-vr-60-1yt52pj.jpg b/public/plaza-covers/other-vr-60-1yt52pj.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-vr-60-1yt52pj.jpg differ diff --git a/public/plaza-covers/other-vr-健身-60-天体验报告.jpg b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg differ diff --git a/public/plaza-covers/other-wishlist.jpg b/public/plaza-covers/other-wishlist.jpg new file mode 100644 index 0000000..6bef7a3 Binary files /dev/null and b/public/plaza-covers/other-wishlist.jpg differ diff --git a/public/plaza-covers/other-xu49pe.jpg b/public/plaza-covers/other-xu49pe.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/other-xu49pe.jpg differ diff --git a/public/plaza-covers/other-个人知识库搭建方法论.jpg b/public/plaza-covers/other-个人知识库搭建方法论.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-个人知识库搭建方法论.jpg differ diff --git a/public/plaza-covers/other-二手交易向可持续生活转型.jpg b/public/plaza-covers/other-二手交易向可持续生活转型.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-二手交易向可持续生活转型.jpg differ diff --git a/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg differ diff --git a/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg differ diff --git a/public/plaza-covers/other-社群运营-从群聊到价值观.jpg b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg differ diff --git a/public/plaza-covers/other-自由职业者财务规划入门.jpg b/public/plaza-covers/other-自由职业者财务规划入门.jpg new file mode 100644 index 0000000..5dca65c Binary files /dev/null and b/public/plaza-covers/other-自由职业者财务规划入门.jpg differ diff --git a/public/plaza-covers/study-notes-6owsyr.jpg b/public/plaza-covers/study-notes-6owsyr.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-6owsyr.jpg differ diff --git a/public/plaza-covers/study-notes-docker-1adiwh7.jpg b/public/plaza-covers/study-notes-docker-1adiwh7.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-docker-1adiwh7.jpg differ diff --git a/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg differ diff --git a/public/plaza-covers/study-notes-fobvok.jpg b/public/plaza-covers/study-notes-fobvok.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/study-notes-fobvok.jpg differ diff --git a/public/plaza-covers/study-notes-git-code-review-1avjty.jpg b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg differ diff --git a/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg differ diff --git a/public/plaza-covers/study-notes-llm.jpg b/public/plaza-covers/study-notes-llm.jpg new file mode 100644 index 0000000..32c7d0c Binary files /dev/null and b/public/plaza-covers/study-notes-llm.jpg differ diff --git a/public/plaza-covers/study-notes-pandas-1mg4mom.jpg b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg differ diff --git a/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg differ diff --git a/public/plaza-covers/study-notes-pca-54364r.jpg b/public/plaza-covers/study-notes-pca-54364r.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-pca-54364r.jpg differ diff --git a/public/plaza-covers/study-notes-rust.jpg b/public/plaza-covers/study-notes-rust.jpg new file mode 100644 index 0000000..3fa0a55 Binary files /dev/null and b/public/plaza-covers/study-notes-rust.jpg differ diff --git a/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg differ diff --git a/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg differ diff --git a/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg differ diff --git a/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg differ diff --git a/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg differ diff --git a/public/plaza-covers/travel-10-ap683v.jpg b/public/plaza-covers/travel-10-ap683v.jpg new file mode 100644 index 0000000..d7437c5 Binary files /dev/null and b/public/plaza-covers/travel-10-ap683v.jpg differ diff --git a/public/plaza-covers/travel-15qb47w.jpg b/public/plaza-covers/travel-15qb47w.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-15qb47w.jpg differ diff --git a/public/plaza-covers/travel-3-1lbpfse.jpg b/public/plaza-covers/travel-3-1lbpfse.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-3-1lbpfse.jpg differ diff --git a/public/plaza-covers/travel-4-70oznz.jpg b/public/plaza-covers/travel-4-70oznz.jpg new file mode 100644 index 0000000..4490c59 Binary files /dev/null and b/public/plaza-covers/travel-4-70oznz.jpg differ diff --git a/public/plaza-covers/travel-48-p5s92m.jpg b/public/plaza-covers/travel-48-p5s92m.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-48-p5s92m.jpg differ diff --git a/public/plaza-covers/travel-6-1hwz8po.jpg b/public/plaza-covers/travel-6-1hwz8po.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-6-1hwz8po.jpg differ diff --git a/public/plaza-covers/travel-fjj9j0.jpg b/public/plaza-covers/travel-fjj9j0.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-fjj9j0.jpg differ diff --git a/public/plaza-covers/travel-kyoto.jpg b/public/plaza-covers/travel-kyoto.jpg new file mode 100644 index 0000000..2bd1d1d Binary files /dev/null and b/public/plaza-covers/travel-kyoto.jpg differ diff --git a/public/plaza-covers/travel-malaysia.jpg b/public/plaza-covers/travel-malaysia.jpg new file mode 100644 index 0000000..2bd1d1d Binary files /dev/null and b/public/plaza-covers/travel-malaysia.jpg differ diff --git a/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg differ diff --git a/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg new file mode 100644 index 0000000..d7437c5 Binary files /dev/null and b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg differ diff --git a/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg differ diff --git a/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg new file mode 100644 index 0000000..4490c59 Binary files /dev/null and b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg differ diff --git a/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg differ diff --git a/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg differ diff --git a/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg differ diff --git a/public/plaza-covers/work-report-1y4i5h1.jpg b/public/plaza-covers/work-report-1y4i5h1.jpg new file mode 100644 index 0000000..206f41a Binary files /dev/null and b/public/plaza-covers/work-report-1y4i5h1.jpg differ diff --git a/public/plaza-covers/work-report-2025-1fz5i3.jpg b/public/plaza-covers/work-report-2025-1fz5i3.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-2025-1fz5i3.jpg differ diff --git a/public/plaza-covers/work-report-72ek9j.jpg b/public/plaza-covers/work-report-72ek9j.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-72ek9j.jpg differ diff --git a/public/plaza-covers/work-report-ai-service.jpg b/public/plaza-covers/work-report-ai-service.jpg new file mode 100644 index 0000000..dc1d298 Binary files /dev/null and b/public/plaza-covers/work-report-ai-service.jpg differ diff --git a/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg differ diff --git a/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg differ diff --git a/public/plaza-covers/work-report-disney.jpg b/public/plaza-covers/work-report-disney.jpg new file mode 100644 index 0000000..49f866d Binary files /dev/null and b/public/plaza-covers/work-report-disney.jpg differ diff --git a/public/plaza-covers/work-report-explore.jpg b/public/plaza-covers/work-report-explore.jpg new file mode 100644 index 0000000..ef2d817 Binary files /dev/null and b/public/plaza-covers/work-report-explore.jpg differ diff --git a/public/plaza-covers/work-report-growth.jpg b/public/plaza-covers/work-report-growth.jpg new file mode 100644 index 0000000..d8959b2 Binary files /dev/null and b/public/plaza-covers/work-report-growth.jpg differ diff --git a/public/plaza-covers/work-report-hello.jpg b/public/plaza-covers/work-report-hello.jpg new file mode 100644 index 0000000..5c69410 Binary files /dev/null and b/public/plaza-covers/work-report-hello.jpg differ diff --git a/public/plaza-covers/work-report-mindspace-qwuy2q.jpg b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg differ diff --git a/public/plaza-covers/work-report-mindspace-公开发布指南.jpg b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg differ diff --git a/public/plaza-covers/work-report-okr-ivflp3.jpg b/public/plaza-covers/work-report-okr-ivflp3.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-okr-ivflp3.jpg differ diff --git a/public/plaza-covers/work-report-q3-1v4dgld.jpg b/public/plaza-covers/work-report-q3-1v4dgld.jpg new file mode 100644 index 0000000..509e7ba Binary files /dev/null and b/public/plaza-covers/work-report-q3-1v4dgld.jpg differ diff --git a/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg differ diff --git a/public/plaza-covers/work-report-supply-chain.jpg b/public/plaza-covers/work-report-supply-chain.jpg new file mode 100644 index 0000000..1782e04 Binary files /dev/null and b/public/plaza-covers/work-report-supply-chain.jpg differ diff --git a/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg differ diff --git a/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg differ diff --git a/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg differ diff --git a/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg new file mode 100644 index 0000000..206f41a Binary files /dev/null and b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg differ diff --git a/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg differ diff --git a/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg new file mode 100644 index 0000000..509e7ba Binary files /dev/null and b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg differ