From 78b7d546c2641ae3507742caee721e1bf7338709 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 10 Sep 2026 16:43:25 +0800 Subject: [PATCH] feat(mindspace): add WeChat MP config and one-click draft push on publish. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users bind their own official account credentials in M 配置 and push MindSpace pages to the WeChat draft box during or after publish, reusing the existing draft conversion pipeline. Co-authored-by: Cursor --- mindspace-wechat-mp-config.mjs | 301 +++++++++++++++ mindspace-wechat-mp-config.test.mjs | 111 ++++++ mindspace-wechat-page-draft.mjs | 360 ++++++++++++++++++ mindspace-wechat-page-draft.test.mjs | 43 +++ package.json | 3 +- scripts/wechat-mp-menu.mjs | 5 + server.mjs | 22 ++ server/portal-mindspace-wechat-routes.mjs | 126 ++++++ .../portal-mindspace-wechat-routes.test.mjs | 104 +++++ src/analytics/productAnalytics.ts | 1 + src/api/client.ts | 8 + src/api/mindspace-wechat-mp.ts | 94 +++++ src/components/MindSpacePageDetail.tsx | 133 ++++++- src/components/MindSpacePublishSuccess.tsx | 16 + src/components/MindSpaceView.tsx | 70 +++- .../MindSpaceWechatMpConfigPanel.tsx | 230 +++++++++++ src/index.css | 19 + src/routes/MindSpaceRoute.tsx | 3 + wechat-news-morning-draft.mjs | 2 +- 19 files changed, 1635 insertions(+), 16 deletions(-) create mode 100644 mindspace-wechat-mp-config.mjs create mode 100644 mindspace-wechat-mp-config.test.mjs create mode 100644 mindspace-wechat-page-draft.mjs create mode 100644 mindspace-wechat-page-draft.test.mjs create mode 100644 server/portal-mindspace-wechat-routes.mjs create mode 100644 server/portal-mindspace-wechat-routes.test.mjs create mode 100644 src/api/mindspace-wechat-mp.ts create mode 100644 src/components/MindSpaceWechatMpConfigPanel.tsx diff --git a/mindspace-wechat-mp-config.mjs b/mindspace-wechat-mp-config.mjs new file mode 100644 index 0000000..3caa0d6 --- /dev/null +++ b/mindspace-wechat-mp-config.mjs @@ -0,0 +1,301 @@ +import { fetch as undiciFetch } from 'undici'; +import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs'; + +const TABLE = 'h5_user_wechat_mp_configs'; +const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token'; +const ACCOUNT_TYPES = new Set(['service', 'subscription']); + +function normalizeAccountType(value, fallback = 'service') { + const normalized = String(value ?? '').trim().toLowerCase(); + return ACCOUNT_TYPES.has(normalized) ? normalized : fallback; +} + +function resolveEncryptionKey(env = process.env) { + return ( + env.H5_SETTINGS_ENCRYPTION_KEY + ?? env.TKMIND_SERVER__SECRET_KEY + ?? 'local-dev-secret' + ); +} + +async function readJsonResponse(response) { + const text = await response.text().catch(() => ''); + if (!response.ok) { + throw new Error(text || `upstream ${response.status}`); + } + return text ? JSON.parse(text) : null; +} + +async function ensureTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + user_id CHAR(36) PRIMARY KEY, + app_id VARCHAR(64) NOT NULL, + account_type ENUM('service', 'subscription') NOT NULL DEFAULT 'service', + label VARCHAR(64) NULL, + author VARCHAR(16) NULL, + secret_ciphertext TEXT NOT NULL, + secret_iv VARCHAR(32) NOT NULL, + secret_tag VARCHAR(32) NOT NULL, + verified_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_wechat_mp_config_app (app_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +function mapRow(row) { + if (!row) return null; + return { + userId: row.user_id, + appId: row.app_id, + accountType: row.account_type, + label: row.label ?? '', + author: row.author ?? '', + secretCiphertext: row.secret_ciphertext, + secretIv: row.secret_iv, + secretTag: row.secret_tag, + verifiedAt: row.verified_at ? Number(row.verified_at) : null, + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0), + }; +} + +function toPublicConfig(row, env = process.env) { + if (!row) { + return { + configured: false, + appId: '', + accountType: 'service', + label: '', + author: '', + appSecretMasked: '', + verifiedAt: null, + updatedAt: null, + }; + } + let appSecretMasked = ''; + try { + const secret = decryptSecret( + { + ciphertext: row.secretCiphertext, + iv: row.secretIv, + tag: row.secretTag, + }, + resolveEncryptionKey(env), + ); + appSecretMasked = maskApiKey(secret); + } catch { + appSecretMasked = '********'; + } + return { + configured: true, + appId: row.appId, + accountType: row.accountType, + label: row.label, + author: row.author, + appSecretMasked, + verifiedAt: row.verifiedAt, + updatedAt: row.updatedAt, + }; +} + +export async function fetchWechatMpAccessToken( + { appId, appSecret }, + { tokenUrl = DEFAULT_TOKEN_URL, wechatFetch = undiciFetch } = {}, +) { + const payload = await readJsonResponse( + await wechatFetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credential', + appid: appId, + secret: appSecret, + force_refresh: false, + }), + }), + ); + if (!payload?.access_token) { + throw new Error(payload?.errmsg || '获取微信 access_token 失败'); + } + return { + accessToken: String(payload.access_token), + expiresIn: Number(payload.expires_in ?? 7200), + }; +} + +export function createMindSpaceWechatMpConfigService( + pool, + { + env = process.env, + tokenUrl = env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL, + wechatFetch = undiciFetch, + } = {}, +) { + let ensurePromise = null; + + async function ensureReady() { + if (!ensurePromise) { + ensurePromise = ensureTable(pool); + } + await ensurePromise; + } + + async function readRow(userId) { + await ensureReady(); + const [rows] = await pool.query( + `SELECT user_id, app_id, account_type, label, author, + secret_ciphertext, secret_iv, secret_tag, + verified_at, created_at, updated_at + FROM ${TABLE} + WHERE user_id = ? + LIMIT 1`, + [userId], + ); + return mapRow(rows[0] ?? null); + } + + function readCredentials(row) { + if (!row) return null; + const appSecret = decryptSecret( + { + ciphertext: row.secretCiphertext, + iv: row.secretIv, + tag: row.secretTag, + }, + resolveEncryptionKey(env), + ); + if (!appSecret) { + throw new Error('公众号密钥解密失败,请重新保存配置'); + } + return { + appId: row.appId, + appSecret, + accountType: row.accountType, + author: row.author, + label: row.label, + }; + } + + return { + async getConfig(userId) { + return toPublicConfig(await readRow(userId), env); + }, + + async getCredentials(userId) { + return readCredentials(await readRow(userId)); + }, + + async upsertConfig( + userId, + { + appId, + appSecret, + accountType, + label, + author, + verify = true, + } = {}, + ) { + await ensureReady(); + const normalizedAppId = String(appId ?? '').trim(); + if (!normalizedAppId) { + throw Object.assign(new Error('请填写 AppID'), { code: 'invalid_wechat_mp_config' }); + } + + const existing = await readRow(userId); + const nextSecret = String(appSecret ?? '').trim(); + let encrypted = null; + if (nextSecret) { + encrypted = encryptSecret(nextSecret, resolveEncryptionKey(env)); + } else if (existing) { + encrypted = { + ciphertext: existing.secretCiphertext, + iv: existing.secretIv, + tag: existing.secretTag, + }; + } else { + throw Object.assign(new Error('请填写 AppSecret'), { code: 'invalid_wechat_mp_config' }); + } + + const credentials = { + appId: normalizedAppId, + appSecret: nextSecret || decryptSecret( + { + ciphertext: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + }, + resolveEncryptionKey(env), + ), + }; + + let verifiedAt = existing?.verifiedAt ?? null; + if (verify) { + await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch }); + verifiedAt = Date.now(); + } + + const now = Date.now(); + await pool.query( + `INSERT INTO ${TABLE} + (user_id, app_id, account_type, label, author, + secret_ciphertext, secret_iv, secret_tag, + verified_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + app_id = VALUES(app_id), + account_type = VALUES(account_type), + label = VALUES(label), + author = VALUES(author), + secret_ciphertext = VALUES(secret_ciphertext), + secret_iv = VALUES(secret_iv), + secret_tag = VALUES(secret_tag), + verified_at = VALUES(verified_at), + updated_at = VALUES(updated_at)`, + [ + userId, + normalizedAppId, + normalizeAccountType(accountType, existing?.accountType ?? 'service'), + String(label ?? existing?.label ?? '').trim().slice(0, 64) || null, + String(author ?? existing?.author ?? '').trim().slice(0, 8) || null, + encrypted.ciphertext, + encrypted.iv, + encrypted.tag, + verifiedAt, + existing?.createdAt ?? now, + now, + ], + ); + return this.getConfig(userId); + }, + + async verifyConfig(userId) { + const row = await readRow(userId); + if (!row) { + throw Object.assign(new Error('尚未配置公众号凭证'), { code: 'wechat_mp_not_configured' }); + } + const credentials = readCredentials(row); + await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch }); + const now = Date.now(); + await pool.query( + `UPDATE ${TABLE} SET verified_at = ?, updated_at = ? WHERE user_id = ?`, + [now, now, userId], + ); + return this.getConfig(userId); + }, + + async deleteConfig(userId) { + await ensureReady(); + await pool.query(`DELETE FROM ${TABLE} WHERE user_id = ?`, [userId]); + return { ok: true }; + }, + }; +} + +export const mindspaceWechatMpConfigInternals = { + normalizeAccountType, + toPublicConfig, +}; diff --git a/mindspace-wechat-mp-config.test.mjs b/mindspace-wechat-mp-config.test.mjs new file mode 100644 index 0000000..b7dfc16 --- /dev/null +++ b/mindspace-wechat-mp-config.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createMindSpaceWechatMpConfigService, + fetchWechatMpAccessToken, + mindspaceWechatMpConfigInternals, +} from './mindspace-wechat-mp-config.mjs'; + +function createPool() { + const rows = new Map(); + return { + rows, + async query(sql, params = []) { + if (sql.includes('CREATE TABLE')) return [[]]; + if (sql.includes('INSERT INTO h5_user_wechat_mp_configs')) { + rows.set(params[0], { + user_id: params[0], + app_id: params[1], + account_type: params[2], + label: params[3], + author: params[4], + secret_ciphertext: params[5], + secret_iv: params[6], + secret_tag: params[7], + verified_at: params[8], + created_at: params[9], + updated_at: params[10], + }); + return [{ affectedRows: 1 }]; + } + if (sql.includes('FROM h5_user_wechat_mp_configs')) { + const row = rows.get(params[0]); + return [[row ?? null].filter(Boolean)]; + } + if (sql.startsWith('UPDATE h5_user_wechat_mp_configs')) { + const row = rows.get(params[2]); + if (row) { + row.verified_at = params[0]; + row.updated_at = params[1]; + } + return [{ affectedRows: 1 }]; + } + if (sql.startsWith('DELETE FROM h5_user_wechat_mp_configs')) { + rows.delete(params[0]); + return [{ affectedRows: 1 }]; + } + throw new Error(`unexpected sql: ${sql}`); + }, + }; +} + +test('upsertConfig stores encrypted secret and returns masked config', async () => { + const pool = createPool(); + const service = createMindSpaceWechatMpConfigService(pool, { + env: { H5_SETTINGS_ENCRYPTION_KEY: 'test-key' }, + wechatFetch: async () => ({ + ok: true, + text: async () => JSON.stringify({ access_token: 'token-1', expires_in: 7200 }), + }), + }); + const config = await service.upsertConfig('user-1', { + appId: 'wx1234567890', + appSecret: 'secret-value-1234567890', + accountType: 'service', + label: '我的服务号', + author: 'TKMind', + }); + assert.equal(config.configured, true); + assert.equal(config.appId, 'wx1234567890'); + assert.ok(config.appSecretMasked.includes('*')); + assert.ok(config.appSecretMasked.endsWith('7890')); + assert.equal(config.label, '我的服务号'); + assert.ok(config.verifiedAt); +}); + +test('verifyConfig refreshes verifiedAt', async () => { + const pool = createPool(); + const service = createMindSpaceWechatMpConfigService(pool, { + env: { H5_SETTINGS_ENCRYPTION_KEY: 'test-key' }, + wechatFetch: async () => ({ + ok: true, + text: async () => JSON.stringify({ access_token: 'token-2', expires_in: 7200 }), + }), + }); + await service.upsertConfig('user-1', { + appId: 'wx1234567890', + appSecret: 'secret-value-1234567890', + }); + const verified = await service.verifyConfig('user-1'); + assert.ok(verified.verifiedAt); +}); + +test('fetchWechatMpAccessToken surfaces upstream error', async () => { + await assert.rejects( + () => fetchWechatMpAccessToken( + { appId: 'bad', appSecret: 'bad' }, + { + wechatFetch: async () => ({ + ok: true, + text: async () => JSON.stringify({ errcode: 40013, errmsg: 'invalid appid' }), + }), + }, + ), + /invalid appid/, + ); +}); + +test('normalizeAccountType defaults to service', () => { + assert.equal(mindspaceWechatMpConfigInternals.normalizeAccountType('subscription'), 'subscription'); + assert.equal(mindspaceWechatMpConfigInternals.normalizeAccountType('unknown'), 'service'); +}); diff --git a/mindspace-wechat-page-draft.mjs b/mindspace-wechat-page-draft.mjs new file mode 100644 index 0000000..43e43cd --- /dev/null +++ b/mindspace-wechat-page-draft.mjs @@ -0,0 +1,360 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fetch as undiciFetch } from 'undici'; +import sharp from 'sharp'; +import { + buildPublicUrl, + PUBLISH_ROOT_DIR, + PUBLIC_ZONE_DIR, + resolvePublicBaseUrl, +} from './user-publish.mjs'; +import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs'; +import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs'; +import { resolvePageWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs'; +import { fetchWechatMpAccessToken } from './mindspace-wechat-mp-config.mjs'; +import { + addWechatDraftArticle, + buildDailyNewsWechatDraftArticleForPush, + convertNewsPageHtmlToWechatArticle, + isDailyNewsFormat, + uploadWechatPermanentThumb, +} from './wechat-news-morning-draft.mjs'; + +const RUNS_TABLE = 'h5_user_wechat_page_draft_runs'; +const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token'; + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function extractMetaDescription(html) { + const match = String(html ?? '').match( + /]+name=["']description["'][^>]+content=["']([^"']*)["']/i, + ); + return match?.[1]?.trim() ?? ''; +} + +function stripHtml(value) { + return String(value ?? '') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function convertGenericPageHtmlToWechatArticle( + html, + { publicUrl = '', pageTitle = '', pageSummary = '' } = {}, +) { + const title = (extractPageTitle(html) || pageTitle || 'TKMind 页面').slice(0, 64); + const digest = (extractMetaDescription(html) || pageSummary || title).slice(0, 120); + const bodyMatch = String(html ?? '').match(/]*>([\s\S]*?)<\/body>/i); + let body = bodyMatch ? bodyMatch[1] : String(html ?? ''); + body = body + .replace(//gi, '') + .replace(//gi, '') + .replace(/]*>/gi, '') + .trim(); + const sections = []; + const summary = stripHtml(pageSummary); + if (summary) { + sections.push( + `

${escapeHtml(summary)}

`, + ); + } + if (body) { + sections.push(`
${body}
`); + } else { + sections.push( + `

${escapeHtml(title)}

`, + ); + } + if (publicUrl) { + sections.push( + `

阅读原文:${escapeHtml(publicUrl)}

`, + ); + } + return { + title, + digest, + content: sections.join('\n').slice(0, 20000), + contentSourceUrl: publicUrl || undefined, + }; +} + +export function convertPageHtmlToWechatArticle( + html, + { publicUrl = '', pageTitle = '', pageSummary = '' } = {}, +) { + if (isDailyNewsFormat(html)) { + return convertNewsPageHtmlToWechatArticle(html, { publicUrl }); + } + const structured = convertNewsPageHtmlToWechatArticle(html, { publicUrl }); + if ((structured.cardCount ?? 0) > 0) { + return structured; + } + return convertGenericPageHtmlToWechatArticle(html, { publicUrl, pageTitle, pageSummary }); +} + +async function ensureRunsTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${RUNS_TABLE} ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + page_id CHAR(36) NOT NULL, + status VARCHAR(16) NOT NULL, + page_title VARCHAR(255) NULL, + page_url VARCHAR(512) NULL, + draft_media_id VARCHAR(128) NULL, + error_message TEXT NULL, + triggered_by CHAR(36) NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_page_draft_user (user_id, created_at), + KEY idx_wechat_page_draft_page (page_id, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +function cryptoRandomId() { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function resolveThumbPath(h5Root, userId, workspaceRelativePath) { + if (!h5Root || !userId || !workspaceRelativePath) return null; + const htmlPath = path.join( + h5Root, + PUBLISH_ROOT_DIR, + String(userId), + PUBLIC_ZONE_DIR, + ...workspaceRelativePath.split('/'), + ); + const pngPath = htmlPath.replace(/\.html$/i, '.thumbnail.png'); + if (fs.existsSync(pngPath)) return pngPath; + const svgPath = htmlPath.replace(/\.html$/i, '.thumbnail.svg'); + if (fs.existsSync(svgPath)) return svgPath; + return null; +} + +async function loadThumbBuffer(thumbPath) { + if (!thumbPath) { + return Buffer.from( + 'TKMind', + ); + } + const raw = fs.readFileSync(thumbPath); + if (/\.svg$/i.test(thumbPath)) { + return sharp(raw).png().toBuffer(); + } + return raw; +} + +export function createMindSpaceWechatPageDraftService( + pool, + { + getMindSpacePages = () => null, + getWechatMpConfig = () => null, + h5Root = process.cwd(), + memindLibRoot = h5Root, + env = process.env, + tokenUrl = env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL, + wechatFetch = undiciFetch, + } = {}, +) { + let ensurePromise = null; + const accessTokenCache = new Map(); + + async function ensureReady() { + if (!ensurePromise) { + ensurePromise = ensureRunsTable(pool); + } + await ensurePromise; + } + + async function getAccessToken(userId) { + const configService = getWechatMpConfig?.(); + if (!configService) throw new Error('公众号配置服务未启用'); + const credentials = await configService.getCredentials(userId); + if (!credentials?.appId || !credentials?.appSecret) { + throw Object.assign(new Error('请先在 M 配置中填写公众号 AppID 和 AppSecret'), { + code: 'wechat_mp_not_configured', + }); + } + const cacheKey = `${userId}:${credentials.appId}`; + const cached = accessTokenCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now() + 60_000) { + return { accessToken: cached.token, credentials }; + } + const token = await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch }); + accessTokenCache.set(cacheKey, { + token: token.accessToken, + expiresAt: Date.now() + token.expiresIn * 1000, + }); + return { accessToken: token.accessToken, credentials }; + } + + async function recordRun(payload) { + await ensureReady(); + const id = cryptoRandomId(); + const now = Date.now(); + await pool.query( + `INSERT INTO ${RUNS_TABLE} + (id, user_id, page_id, status, page_title, page_url, draft_media_id, error_message, triggered_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + payload.userId, + payload.pageId, + payload.status, + payload.pageTitle ?? null, + payload.pageUrl ?? null, + payload.draftMediaId ?? null, + payload.errorMessage ?? null, + payload.triggeredBy ?? null, + now, + ], + ); + return { + id, + status: payload.status, + pageId: payload.pageId, + pageTitle: payload.pageTitle ?? null, + pageUrl: payload.pageUrl ?? null, + draftMediaId: payload.draftMediaId ?? null, + errorMessage: payload.errorMessage ?? null, + triggeredBy: payload.triggeredBy ?? null, + createdAt: now, + }; + } + + async function resolvePageHtml(userId, pageId) { + const pages = getMindSpacePages?.(); + if (!pages?.renderPreview || !pages?.getPage) { + throw new Error('MindSpace 页面服务未启用'); + } + const page = await pages.getPage(userId, pageId); + const { html } = await pages.renderPreview(userId, pageId); + const workspaceRelativePath = resolvePageWorkspaceRelativePath(page); + const publicBaseUrl = resolvePublicBaseUrl(env); + const publicUrl = page.publication?.publicUrl + ?? (workspaceRelativePath + ? buildPublicUrl(publicBaseUrl, userId, workspaceRelativePath) + : ''); + let sourceHtml = html; + if (workspaceRelativePath) { + const workspaceHtml = await readWorkspacePublishHtml(h5Root, userId, workspaceRelativePath); + if (workspaceHtml?.trim()) { + sourceHtml = workspaceHtml; + } + } + return { + page, + html: sourceHtml, + publicUrl, + workspaceRelativePath, + }; + } + + return { + async pushPageDraft(userId, pageId, { triggeredBy = null } = {}) { + let preview = null; + try { + preview = await resolvePageHtml(userId, pageId); + const { accessToken, credentials } = await getAccessToken(userId); + const article = isDailyNewsFormat(preview.html) + ? await buildDailyNewsWechatDraftArticleForPush({ + html: preview.html, + publicUrl: preview.publicUrl, + accessToken, + wechatFetch, + memindLibRoot, + }) + : convertPageHtmlToWechatArticle(preview.html, { + publicUrl: preview.publicUrl, + pageTitle: preview.page.title, + pageSummary: preview.page.summary, + }); + const thumbPath = resolveThumbPath( + h5Root, + userId, + preview.workspaceRelativePath, + ); + const thumbBuffer = await loadThumbBuffer(thumbPath); + const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch }); + const draft = await addWechatDraftArticle( + accessToken, + { + title: article.title, + author: credentials.author || 'TKMind', + digest: article.digest, + content: article.content, + content_source_url: article.contentSourceUrl, + thumb_media_id: thumbMediaId, + need_open_comment: 0, + only_fans_can_comment: 0, + }, + { wechatFetch }, + ); + const run = await recordRun({ + userId, + pageId, + status: 'success', + pageTitle: preview.page.title, + pageUrl: preview.publicUrl, + draftMediaId: draft.draftMediaId, + triggeredBy, + }); + return { + ok: true, + draftMediaId: draft.draftMediaId, + pageTitle: preview.page.title, + pageUrl: preview.publicUrl, + run, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const run = await recordRun({ + userId, + pageId, + status: 'failed', + pageTitle: preview?.page?.title ?? null, + pageUrl: preview?.publicUrl ?? null, + errorMessage: message, + triggeredBy, + }); + throw Object.assign(new Error(message), { code: error?.code, run }); + } + }, + + async listRuns(userId, { pageId = null, limit = 20 } = {}) { + await ensureReady(); + const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + const params = [userId]; + let sql = ` + SELECT id, page_id AS pageId, status, page_title AS pageTitle, page_url AS pageUrl, + draft_media_id AS draftMediaId, error_message AS errorMessage, + triggered_by AS triggeredBy, created_at AS createdAt + FROM ${RUNS_TABLE} + WHERE user_id = ?`; + if (pageId) { + sql += ' AND page_id = ?'; + params.push(pageId); + } + sql += ' ORDER BY created_at DESC LIMIT ?'; + params.push(safeLimit); + const [rows] = await pool.query(sql, params); + return rows.map((row) => ({ + ...row, + createdAt: Number(row.createdAt ?? 0), + })); + }, + }; +} + +export const mindspaceWechatPageDraftInternals = { + convertGenericPageHtmlToWechatArticle, + convertPageHtmlToWechatArticle, +}; diff --git a/mindspace-wechat-page-draft.test.mjs b/mindspace-wechat-page-draft.test.mjs new file mode 100644 index 0000000..ab1fa3b --- /dev/null +++ b/mindspace-wechat-page-draft.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + convertGenericPageHtmlToWechatArticle, + convertPageHtmlToWechatArticle, + mindspaceWechatPageDraftInternals, +} from './mindspace-wechat-page-draft.mjs'; + +const SAMPLE_HTML = ` +测试页面 + +

标题

正文内容

`; + +test('convertGenericPageHtmlToWechatArticle extracts title and body', () => { + const article = convertGenericPageHtmlToWechatArticle(SAMPLE_HTML, { + publicUrl: 'https://m.tkmind.cn/u/demo/pages/test.html', + pageTitle: '测试页面', + pageSummary: '页面摘要', + }); + assert.equal(article.title, '测试页面'); + assert.equal(article.digest, '页面摘要'); + assert.match(article.content, /正文内容/); + assert.match(article.content, /阅读原文/); +}); + +test('convertPageHtmlToWechatArticle falls back to generic converter', () => { + const article = convertPageHtmlToWechatArticle(SAMPLE_HTML, { + publicUrl: 'https://example.com/page.html', + pageTitle: '测试页面', + pageSummary: '页面摘要', + }); + assert.equal(article.title, '测试页面'); + assert.match(article.content, /正文内容/); +}); + +test('convertGenericPageHtmlToWechatArticle truncates long content', () => { + const longBody = `

${'很长'.repeat(12000)}

`; + const article = mindspaceWechatPageDraftInternals.convertGenericPageHtmlToWechatArticle( + `${longBody}`, + { pageTitle: '长文' }, + ); + assert.ok(article.content.length <= 20000); +}); diff --git a/package.json b/package.json index 61ff671..480c651 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "verify:experience-agent-run-local": "node scripts/verify-experience-agent-run-local.mjs", "verify:experience-reflect-local": "node scripts/verify-experience-reflect-local.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs health-channel-state.test.mjs health-intent-rules.test.mjs health-extraction.test.mjs health-observation-validate.test.mjs health-baseline-maturity.test.mjs health-publish-guard.test.mjs health-p0-experiment.test.mjs health-wechat-channel.test.mjs server/portal-health-routes.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs health-channel-state.test.mjs health-intent-rules.test.mjs health-extraction.test.mjs health-observation-validate.test.mjs health-baseline-maturity.test.mjs health-publish-guard.test.mjs health-p0-experiment.test.mjs health-wechat-channel.test.mjs server/portal-health-routes.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-wechat-mp-config.test.mjs mindspace-wechat-page-draft.test.mjs server/portal-mindspace-wechat-routes.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", @@ -114,6 +114,7 @@ "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", + "verify:mindspace-wechat-mp": "node --test mindspace-wechat-mp-config.test.mjs mindspace-wechat-page-draft.test.mjs server/portal-mindspace-wechat-routes.test.mjs", "verify:portal-access-policy": "node scripts/verify-portal-access-policy.mjs", "verify:seo-discovery": "node scripts/verify-seo-discovery.mjs", "verify:seo-geo": "node --test mindspace-index-policy.test.mjs mindspace-seo-tags.test.mjs mindspace-geo-tags.test.mjs mindspace-seo-geo-delivery.test.mjs mindspace-seo-discovery-service.test.mjs mindspace-seo-notify.test.mjs mindspace-config.test.mjs server/portal-seo-discovery-routes.test.mjs", diff --git a/scripts/wechat-mp-menu.mjs b/scripts/wechat-mp-menu.mjs index 31625ce..1ccce73 100755 --- a/scripts/wechat-mp-menu.mjs +++ b/scripts/wechat-mp-menu.mjs @@ -33,6 +33,11 @@ const MENU = { name: '空间首页', url: 'https://m.tkmind.cn/space', }, + { + type: 'view', + name: 'M配置', + url: 'https://m.tkmind.cn/space/wechat-config', + }, { type: 'view', name: 'M成果', diff --git a/server.mjs b/server.mjs index 72806c3..665ceea 100644 --- a/server.mjs +++ b/server.mjs @@ -53,7 +53,10 @@ import { attachPortalMindSpaceChatSaveRoutes } from './server/portal-mindspace-c import { attachPortalMindSpaceChatShareRoutes } from './server/portal-mindspace-chat-share-routes.mjs'; import { attachPortalMindSpacePageCoreRoutes } from './server/portal-mindspace-page-core-routes.mjs'; import { attachPortalMindSpacePagePublishRoutes } from './server/portal-mindspace-page-publish-routes.mjs'; +import { attachPortalMindSpaceWechatRoutes } from './server/portal-mindspace-wechat-routes.mjs'; import { attachPortalMindSpaceSpaceRoutes } from './server/portal-mindspace-space-routes.mjs'; +import { createMindSpaceWechatMpConfigService } from './mindspace-wechat-mp-config.mjs'; +import { createMindSpaceWechatPageDraftService } from './mindspace-wechat-page-draft.mjs'; import { attachPortalTemplateCatalogRoutes } from './server/portal-template-catalog-routes.mjs'; import { attachPortalPlazaDiscoveryRoutes } from './server/portal-plaza-discovery-routes.mjs'; import { attachPortalPlazaRoutes } from './server/portal-plaza-routes.mjs'; @@ -320,6 +323,8 @@ let mindSpacePageLiveEdit = null; let mindSpaceAssetAgent = null; let mindSpacePageEditSession = null; let mindSpacePublications = null; +let mindSpaceWechatMpConfig = null; +let mindSpaceWechatPageDraft = null; let workspacePageDeliver = null; let plazaPosts = null; let plazaEvents = null; @@ -445,6 +450,16 @@ async function bootstrapUserAuth() { plazaPosts = domainServices.plazaPosts; plazaOps = domainServices.plazaOps; mindSpaceCleanup = domainServices.mindSpaceCleanup; + mindSpaceWechatMpConfig = createMindSpaceWechatMpConfigService(pool, { + env: process.env, + }); + mindSpaceWechatPageDraft = createMindSpaceWechatPageDraftService(pool, { + getMindSpacePages: () => mindSpacePages, + getWechatMpConfig: () => mindSpaceWechatMpConfig, + h5Root: H5_ROOT, + memindLibRoot: __dirname, + env: process.env, + }); const authServices = await bootstrapPortalAuthServices({ pool, @@ -1457,6 +1472,13 @@ attachPortalMindSpacePagePublishRoutes(api, { handleMindSpaceError: mindSpaceError, }); +attachPortalMindSpaceWechatRoutes(api, { + getWechatMpConfig: () => mindSpaceWechatMpConfig, + getWechatPageDraft: () => mindSpaceWechatPageDraft, + sendData, + handleMindSpaceError: mindSpaceError, +}); + attachPortalPlazaDiscoveryRoutes(api, { getPlazaPosts: () => plazaPosts, getPlazaSeo: () => plazaSeo, diff --git a/server/portal-mindspace-wechat-routes.mjs b/server/portal-mindspace-wechat-routes.mjs new file mode 100644 index 0000000..414bf87 --- /dev/null +++ b/server/portal-mindspace-wechat-routes.mjs @@ -0,0 +1,126 @@ +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' || + typeof api.put !== 'function' || + typeof api.delete !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceWechatRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpaceWechatRoutes( + api, + { + getWechatMpConfig = () => null, + getWechatPageDraft = () => null, + sendData, + handleMindSpaceError, + } = {}, +) { + assertRouter(api); + if (typeof sendData !== 'function' || typeof handleMindSpaceError !== 'function') { + throw new Error( + 'attachPortalMindSpaceWechatRoutes requires response dependencies', + ); + } + + api.get('/mindspace/v1/wechat-mp/config', async (req, res) => { + const service = getWechatMpConfig(); + if (!service) { + return res.status(503).json({ message: '公众号配置未启用' }); + } + try { + return sendData(res, req, await service.getConfig(req.currentUser.id)); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.put('/mindspace/v1/wechat-mp/config', async (req, res) => { + const service = getWechatMpConfig(); + if (!service) { + return res.status(503).json({ message: '公众号配置未启用' }); + } + try { + return sendData( + res, + req, + await service.upsertConfig(req.currentUser.id, { + appId: req.body?.app_id ?? req.body?.appId, + appSecret: req.body?.app_secret ?? req.body?.appSecret, + accountType: req.body?.account_type ?? req.body?.accountType, + label: req.body?.label, + author: req.body?.author, + verify: req.body?.verify !== false, + }), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/mindspace/v1/wechat-mp/config/verify', async (req, res) => { + const service = getWechatMpConfig(); + if (!service) { + return res.status(503).json({ message: '公众号配置未启用' }); + } + try { + return sendData(res, req, await service.verifyConfig(req.currentUser.id)); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.delete('/mindspace/v1/wechat-mp/config', async (req, res) => { + const service = getWechatMpConfig(); + if (!service) { + return res.status(503).json({ message: '公众号配置未启用' }); + } + try { + return sendData(res, req, await service.deleteConfig(req.currentUser.id)); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/mindspace/v1/pages/:pageId/wechat-draft', async (req, res) => { + const service = getWechatPageDraft(); + if (!service) { + return res.status(503).json({ message: '公众号草稿推送未启用' }); + } + try { + return sendData( + res, + req, + await service.pushPageDraft(req.currentUser.id, req.params.pageId, { + triggeredBy: req.currentUser.id, + }), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/pages/:pageId/wechat-draft/runs', async (req, res) => { + const service = getWechatPageDraft(); + if (!service) { + return res.status(503).json({ message: '公众号草稿推送未启用' }); + } + try { + return sendData( + res, + req, + await service.listRuns(req.currentUser.id, { + pageId: req.params.pageId, + limit: req.query?.limit, + }), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); +} diff --git a/server/portal-mindspace-wechat-routes.test.mjs b/server/portal-mindspace-wechat-routes.test.mjs new file mode 100644 index 0000000..86f8f77 --- /dev/null +++ b/server/portal-mindspace-wechat-routes.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpaceWechatRoutes } from './portal-mindspace-wechat-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + put(path, handler) { + routes.set(`PUT ${path}`, handler); + }, + delete(path, handler) { + routes.set(`DELETE ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + params: { pageId: 'page-1' }, + query: {}, + currentUser: { id: 'user-1' }, + requestId: 'request-1', + ...overrides, + }; +} + +test('wechat config routes delegate to services', async () => { + const calls = []; + const api = createRouterRecorder(); + attachPortalMindSpaceWechatRoutes(api, { + getWechatMpConfig: () => ({ + async getConfig(userId) { + calls.push(['getConfig', userId]); + return { configured: true, appId: 'wx-test' }; + }, + async upsertConfig(userId, payload) { + calls.push(['upsertConfig', userId, payload]); + return { configured: true, appId: payload.appId }; + }, + async verifyConfig(userId) { + calls.push(['verifyConfig', userId]); + return { configured: true, verifiedAt: 1 }; + }, + async deleteConfig(userId) { + calls.push(['deleteConfig', userId]); + return { ok: true }; + }, + }), + getWechatPageDraft: () => ({ + async pushPageDraft(userId, pageId) { + calls.push(['pushPageDraft', userId, pageId]); + return { ok: true, draftMediaId: 'draft-1' }; + }, + async listRuns(userId, options) { + calls.push(['listRuns', userId, options]); + return []; + }, + }), + sendData(res, req, data) { + return res.status(200).json({ data }); + }, + handleMindSpaceError(res, req, error) { + return res.status(400).json({ message: error.message }); + }, + }); + + const getRes = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/wechat-mp/config')(createRequest(), getRes); + assert.deepEqual(calls[0], ['getConfig', 'user-1']); + + const putRes = createResponseRecorder(); + await api.routes.get('PUT /mindspace/v1/wechat-mp/config')( + createRequest({ body: { app_id: 'wx-new', app_secret: 'secret' } }), + putRes, + ); + assert.equal(calls.at(-1)[0], 'upsertConfig'); + + const pushRes = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/pages/:pageId/wechat-draft')(createRequest(), pushRes); + assert.deepEqual(calls.at(-1), ['pushPageDraft', 'user-1', 'page-1']); +}); diff --git a/src/analytics/productAnalytics.ts b/src/analytics/productAnalytics.ts index 93de95b..e497575 100644 --- a/src/analytics/productAnalytics.ts +++ b/src/analytics/productAnalytics.ts @@ -55,6 +55,7 @@ export function resolveProductRouteName(pathname: string) { if (pathname === '/') return 'chat'; if (pathname === '/space') return 'mindspace_home'; if (pathname === '/space/achievements') return 'mindspace_achievements'; + if (pathname === '/space/wechat-config') return 'mindspace_wechat_config'; if (pathname.startsWith('/space/page/')) return 'mindspace_page'; if (pathname.startsWith('/feedback/')) return 'feedback_detail'; if (pathname === '/feedback') return 'feedback'; diff --git a/src/api/client.ts b/src/api/client.ts index 3087942..ab434f7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -110,6 +110,14 @@ export { redactMindSpacePage, updatePublicationStatus, } from './mindspace-publications'; +export { + deleteMindSpaceWechatMpConfig, + getMindSpaceWechatMpConfig, + listMindSpaceWechatPageDraftRuns, + pushMindSpacePageWechatDraft, + saveMindSpaceWechatMpConfig, + verifyMindSpaceWechatMpConfig, +} from './mindspace-wechat-mp'; export { applyPageDataPublishPolicy, buildPageDataExportUrl, diff --git a/src/api/mindspace-wechat-mp.ts b/src/api/mindspace-wechat-mp.ts new file mode 100644 index 0000000..af663a1 --- /dev/null +++ b/src/api/mindspace-wechat-mp.ts @@ -0,0 +1,94 @@ +import { apiFetch } from './core'; + +export type MindSpaceWechatMpAccountType = 'service' | 'subscription'; + +export type MindSpaceWechatMpConfig = { + configured: boolean; + appId: string; + accountType: MindSpaceWechatMpAccountType; + label: string; + author: string; + appSecretMasked: string; + verifiedAt: number | null; + updatedAt: number | null; +}; + +export type MindSpaceWechatPageDraftRun = { + id: string; + pageId: string; + status: 'success' | 'failed' | string; + pageTitle: string | null; + pageUrl: string | null; + draftMediaId: string | null; + errorMessage: string | null; + triggeredBy: string | null; + createdAt: number; +}; + +export type MindSpaceWechatPageDraftResult = { + ok: boolean; + draftMediaId: string; + pageTitle: string; + pageUrl: string; + run: MindSpaceWechatPageDraftRun; +}; + +export async function getMindSpaceWechatMpConfig(): Promise { + const result = await apiFetch<{ data: MindSpaceWechatMpConfig }>('/mindspace/v1/wechat-mp/config'); + return result.data; +} + +export async function saveMindSpaceWechatMpConfig(input: { + appId: string; + appSecret?: string; + accountType?: MindSpaceWechatMpAccountType; + label?: string; + author?: string; + verify?: boolean; +}): Promise { + const result = await apiFetch<{ data: MindSpaceWechatMpConfig }>('/mindspace/v1/wechat-mp/config', { + method: 'PUT', + body: JSON.stringify({ + app_id: input.appId, + app_secret: input.appSecret, + account_type: input.accountType, + label: input.label, + author: input.author, + verify: input.verify, + }), + }); + return result.data; +} + +export async function verifyMindSpaceWechatMpConfig(): Promise { + const result = await apiFetch<{ data: MindSpaceWechatMpConfig }>( + '/mindspace/v1/wechat-mp/config/verify', + { method: 'POST' }, + ); + return result.data; +} + +export async function deleteMindSpaceWechatMpConfig(): Promise<{ ok: boolean }> { + const result = await apiFetch<{ data: { ok: boolean } }>('/mindspace/v1/wechat-mp/config', { + method: 'DELETE', + }); + return result.data; +} + +export async function pushMindSpacePageWechatDraft(pageId: string): Promise { + const result = await apiFetch<{ data: MindSpaceWechatPageDraftResult }>( + `/mindspace/v1/pages/${encodeURIComponent(pageId)}/wechat-draft`, + { method: 'POST' }, + ); + return result.data; +} + +export async function listMindSpaceWechatPageDraftRuns( + pageId: string, + limit = 10, +): Promise { + const result = await apiFetch<{ data: MindSpaceWechatPageDraftRun[] }>( + `/mindspace/v1/pages/${encodeURIComponent(pageId)}/wechat-draft/runs?limit=${limit}`, + ); + return result.data; +} diff --git a/src/components/MindSpacePageDetail.tsx b/src/components/MindSpacePageDetail.tsx index 8fbe5dd..9409bde 100644 --- a/src/components/MindSpacePageDetail.tsx +++ b/src/components/MindSpacePageDetail.tsx @@ -18,6 +18,10 @@ import { updateMindSpacePage, updatePublicationStatus, } from '../api/client'; +import { + getMindSpaceWechatMpConfig, + pushMindSpacePageWechatDraft, +} from '../api/mindspace-wechat-mp'; import type { MindSpacePage, MindSpacePageDeletePreview, @@ -319,16 +323,52 @@ export function MindSpacePageDetail({ const [plazaSubmitting, setPlazaSubmitting] = useState(false); const [plazaPost, setPlazaPost] = useState(null); const [pushToPlaza, setPushToPlaza] = useState(false); + const [pushToWechatDraft, setPushToWechatDraft] = useState(false); + const [wechatMpConfigured, setWechatMpConfigured] = useState(false); + const [wechatDraftPushing, setWechatDraftPushing] = useState(false); + const [wechatDraftNotice, setWechatDraftNotice] = useState(null); const [fixNotice, setFixNotice] = useState(null); const [publishSuccess, setPublishSuccess] = useState<{ publication: MindSpacePublication; plazaPost: PlazaPostBrief | null; pageTitle: string; pushedToPlaza: boolean; + pushedToWechatDraft: boolean; + wechatDraftMediaId?: string | null; + wechatDraftError?: string | null; } | null>(null); const [confirmPublicationStatusOpen, setConfirmPublicationStatusOpen] = useState(false); const [statusConfirming, setStatusConfirming] = useState(false); + useEffect(() => { + let cancelled = false; + void getMindSpaceWechatMpConfig() + .then((config) => { + if (!cancelled) setWechatMpConfigured(Boolean(config.configured)); + }) + .catch(() => { + if (!cancelled) setWechatMpConfigured(false); + }); + return () => { + cancelled = true; + }; + }, [pageId]); + + useEffect(() => { + if (!publishOpen) return; + let cancelled = false; + void getMindSpaceWechatMpConfig() + .then((config) => { + if (!cancelled) setWechatMpConfigured(Boolean(config.configured)); + }) + .catch(() => { + if (!cancelled) setWechatMpConfigured(false); + }); + return () => { + cancelled = true; + }; + }, [publishOpen]); + useEffect(() => { if (!publishOpen) return; let cancelled = false; @@ -559,6 +599,16 @@ export function MindSpacePageDetail({ if (pushToPlaza) { nextPlazaPost = await publishToPlazaWithDefaults(publication.id); } + let wechatDraftMediaId: string | null = null; + let wechatDraftError: string | null = null; + if (pushToWechatDraft) { + try { + const draftResult = await pushMindSpacePageWechatDraft(page.id); + wechatDraftMediaId = draftResult.draftMediaId; + } catch (err) { + wechatDraftError = err instanceof Error ? err.message : '推送到公众号草稿箱失败'; + } + } setPublishOpen(false); setFullscreenPreviewOpen(false); onFullscreenPreviewChange?.(false); @@ -567,10 +617,19 @@ export function MindSpacePageDetail({ plazaPost: nextPlazaPost, pageTitle: title || page.title, pushedToPlaza: pushToPlaza, + pushedToWechatDraft: pushToWechatDraft, + wechatDraftMediaId, + wechatDraftError, }); await onSaved(); } catch (err) { - setError(err instanceof Error ? err.message : pushToPlaza ? '发布或推送到 Plaza 失败' : '发布失败'); + setError( + err instanceof Error + ? err.message + : pushToPlaza || pushToWechatDraft + ? '发布或后续推送失败' + : '发布失败', + ); } finally { setPublishing(false); } @@ -645,6 +704,25 @@ export function MindSpacePageDetail({ } }; + const pushWechatDraft = async () => { + if (!page) return; + if (!wechatMpConfigured) { + setError('请先在 M 配置中绑定公众号 AppID 和 AppSecret。'); + return; + } + setWechatDraftPushing(true); + setError(null); + setWechatDraftNotice(null); + try { + const result = await pushMindSpacePageWechatDraft(page.id); + setWechatDraftNotice(`已推送到公众号草稿箱。草稿 media_id:${result.draftMediaId}`); + } catch (err) { + setError(err instanceof Error ? err.message : '推送到公众号草稿箱失败'); + } finally { + setWechatDraftPushing(false); + } + }; + const submitPlaza = async () => { if (!page?.publication || !plazaCategoryId) return; setPlazaSubmitting(true); @@ -986,6 +1064,9 @@ export function MindSpacePageDetail({ publication={publishSuccess.publication} plazaPost={publishSuccess.plazaPost} pushedToPlaza={publishSuccess.pushedToPlaza} + pushedToWechatDraft={publishSuccess.pushedToWechatDraft} + wechatDraftMediaId={publishSuccess.wechatDraftMediaId} + wechatDraftError={publishSuccess.wechatDraftError} offlineBusy={publishing} error={error} onOffline={offlineFromSuccess} @@ -1104,6 +1185,15 @@ export function MindSpacePageDetail({ )} + {wechatDraftNotice ? ( +
+
+ 公众号草稿箱 + {wechatDraftNotice} +
+
+ ) : null} + {page.publication && (
@@ -1139,6 +1229,16 @@ export function MindSpacePageDetail({ 发布到广场 ) : null} + {page.publication.status === 'online' ? ( + + ) : null}
diff --git a/src/components/MindSpacePublishSuccess.tsx b/src/components/MindSpacePublishSuccess.tsx index 74c98a7..bfeb8c9 100644 --- a/src/components/MindSpacePublishSuccess.tsx +++ b/src/components/MindSpacePublishSuccess.tsx @@ -10,6 +10,9 @@ export function MindSpacePublishSuccess({ publication, plazaPost, pushedToPlaza = false, + pushedToWechatDraft = false, + wechatDraftMediaId = null, + wechatDraftError = null, offlineBusy = false, error, onOffline, @@ -19,6 +22,9 @@ export function MindSpacePublishSuccess({ publication: MindSpacePublication; plazaPost?: PlazaPostBrief | null; pushedToPlaza?: boolean; + pushedToWechatDraft?: boolean; + wechatDraftMediaId?: string | null; + wechatDraftError?: string | null; offlineBusy?: boolean; error?: string | null; onOffline: () => void | Promise; @@ -50,6 +56,16 @@ export function MindSpacePublishSuccess({ 尚未推送到 Plaza。公开页已生效,如需上广场请在页面详情中点击「发布到广场」。

)} + {pushedToWechatDraft && !wechatDraftError ? ( +

+ 已推送到公众号草稿箱{wechatDraftMediaId ? `(media_id:${wechatDraftMediaId})` : ''}。请登录微信公众平台审阅后发表。 +

+ ) : null} + {wechatDraftError ? ( +
+ 页面已发布,但公众号草稿箱推送失败:{wechatDraftError} +
+ ) : null} {error ?
{error}
: null}
void; pushAchievements: () => void; + pushWechatConfig: () => void; pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void; pushPage: (pageId: string) => void; }; @@ -539,6 +541,7 @@ export function MindSpaceView({ initialPageId, initialCategoryCode, initialAchievementsOpen = false, + initialWechatConfigOpen = false, onBack, onLogout, onOpenFeedback, @@ -550,6 +553,7 @@ export function MindSpaceView({ initialPageId?: string | null; initialCategoryCode?: MindSpaceSaveCategory | 'health' | null; initialAchievementsOpen?: boolean; + initialWechatConfigOpen?: boolean; onBack: () => void; onLogout: () => void; onOpenFeedback?: () => void; @@ -603,6 +607,7 @@ export function MindSpaceView({ const [allPagesPageIndex, setAllPagesPageIndex] = useState(0); const [allPagesLoading, setAllPagesLoading] = useState(false); const [achievementsOpen, setAchievementsOpen] = useState(initialAchievementsOpen); + const [wechatConfigOpen, setWechatConfigOpen] = useState(initialWechatConfigOpen); const [achievementsItems, setAchievementsItems] = useState([]); const [achievementsTotal, setAchievementsTotal] = useState(0); const [achievementsPageIndex, setAchievementsPageIndex] = useState(0); @@ -669,11 +674,11 @@ export function MindSpaceView({ }, [selectedPageId]); useEffect(() => { - if (!allPagesOpen && !achievementsOpen && !selectedPageId) return; + if (!allPagesOpen && !achievementsOpen && !wechatConfigOpen && !selectedPageId) return; window.requestAnimationFrame(() => { window.scrollTo({ top: 0, left: 0 }); }); - }, [allPagesOpen, achievementsOpen, selectedPageId]); + }, [allPagesOpen, achievementsOpen, wechatConfigOpen, selectedPageId]); useEffect(() => { if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return; @@ -1002,6 +1007,7 @@ export function MindSpaceView({ setNewPageOpen(false); setAgentJobsPanelOpen(false); setAchievementsOpen(false); + setWechatConfigOpen(false); setAllPagesOpen(true); setSelectedAllPageIds([]); routeSync?.pushHome(); @@ -1041,6 +1047,7 @@ export function MindSpaceView({ setNewPageOpen(false); setAgentJobsPanelOpen(false); setAllPagesOpen(false); + setWechatConfigOpen(false); setAchievementsOpen(true); setSelectedAchievementPageIds([]); routeSync?.pushAchievements(); @@ -1053,6 +1060,22 @@ export function MindSpaceView({ routeSync?.pushHome(); }; + const openWechatConfigPanel = () => { + setSelectedCategory(null); + setSelectedPageId(null); + setNewPageOpen(false); + setAgentJobsPanelOpen(false); + setAllPagesOpen(false); + setAchievementsOpen(false); + setWechatConfigOpen(true); + routeSync?.pushWechatConfig(); + }; + + const closeWechatConfigPanel = () => { + setWechatConfigOpen(false); + routeSync?.pushHome(); + }; + const refreshAgentJobsSummary = async () => { if (previewMode) return; const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 }); @@ -1088,6 +1111,7 @@ export function MindSpaceView({ setNewPageOpen(false); setAllPagesOpen(false); setAchievementsOpen(false); + setWechatConfigOpen(false); setAgentJobsPanelOpen(true); routeSync?.pushHome(); void loadAgentJobsPage(0); @@ -1206,7 +1230,7 @@ export function MindSpaceView({ useEffect(() => { if (previewMode) return; const refreshSchedule = () => { - if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen) { + if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && !wechatConfigOpen) { void refreshSpaceQuietly(); } }; @@ -1223,6 +1247,7 @@ export function MindSpaceView({ agentJobsPanelOpen, allPagesOpen, achievementsOpen, + wechatConfigOpen, ]); useEffect(() => { @@ -1230,11 +1255,11 @@ export function MindSpaceView({ }, [initialPageId]); useEffect(() => { - if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen) return; + if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen || initialWechatConfigOpen) return; if (initialCategoryCode === 'health' && !healthEnabled) return; const category = space.categories.find((item) => item.code === initialCategoryCode); if (category) void openCategory(category); - }, [space, initialCategoryCode, initialAchievementsOpen, healthEnabled]); + }, [space, initialCategoryCode, initialAchievementsOpen, initialWechatConfigOpen, healthEnabled]); useEffect(() => { if (!initialAchievementsOpen || previewMode) return; @@ -1243,10 +1268,22 @@ export function MindSpaceView({ setNewPageOpen(false); setAgentJobsPanelOpen(false); setAllPagesOpen(false); + setWechatConfigOpen(false); setAchievementsOpen(true); void loadAchievementsPage(0); }, [initialAchievementsOpen, previewMode]); + useEffect(() => { + if (!initialWechatConfigOpen || previewMode) return; + setSelectedCategory(null); + setSelectedPageId(null); + setNewPageOpen(false); + setAgentJobsPanelOpen(false); + setAllPagesOpen(false); + setAchievementsOpen(false); + setWechatConfigOpen(true); + }, [initialWechatConfigOpen, previewMode]); + useEffect(() => { if (previewMode || !agentJob || !['queued', 'running'].includes(agentJob.status)) return; const timer = window.setInterval(() => { @@ -2107,7 +2144,8 @@ export function MindSpaceView({ !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && - !achievementsOpen; + !achievementsOpen && + !wechatConfigOpen; const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? []; const scheduleReminders = previewMode ? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item })) @@ -2247,7 +2285,7 @@ export function MindSpaceView({
- {!allPagesOpen && !achievementsOpen && ( + {!allPagesOpen && !achievementsOpen && !wechatConfigOpen && (

MINDSPACE

@@ -2270,6 +2308,7 @@ export function MindSpaceView({ setSelectedPageId(null); setAllPagesOpen(false); setAchievementsOpen(false); + setWechatConfigOpen(false); setNewPageOpen(true); }} > @@ -2544,7 +2583,7 @@ export function MindSpaceView({
站内浏览
@@ -2626,6 +2665,8 @@ export function MindSpaceView({
+ ) : wechatConfigOpen ? ( + ) : achievementsOpen ? (
+
+
+ WECHAT + 草稿箱 +
+

M 配置

+

绑定你的服务号或订阅号 AppID / AppSecret,用于一键推送到草稿箱。

+ +
SHOWCASE @@ -3269,7 +3321,7 @@ export function MindSpaceView({ )} - {!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && ( + {!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && !wechatConfigOpen && (
diff --git a/src/components/MindSpaceWechatMpConfigPanel.tsx b/src/components/MindSpaceWechatMpConfigPanel.tsx new file mode 100644 index 0000000..d3f5cbe --- /dev/null +++ b/src/components/MindSpaceWechatMpConfigPanel.tsx @@ -0,0 +1,230 @@ +import { useEffect, useState } from 'react'; +import { + deleteMindSpaceWechatMpConfig, + getMindSpaceWechatMpConfig, + saveMindSpaceWechatMpConfig, + verifyMindSpaceWechatMpConfig, + type MindSpaceWechatMpAccountType, + type MindSpaceWechatMpConfig, +} from '../api/mindspace-wechat-mp'; + +function formatTime(value: number | null | undefined) { + if (!value) return '—'; + return new Date(value).toLocaleString('zh-CN', { hour12: false }); +} + +type MindSpaceWechatMpConfigPanelProps = { + onBack: () => void; +}; + +export function MindSpaceWechatMpConfigPanel({ onBack }: MindSpaceWechatMpConfigPanelProps) { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [verifying, setVerifying] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [config, setConfig] = useState(null); + const [appId, setAppId] = useState(''); + const [appSecret, setAppSecret] = useState(''); + const [accountType, setAccountType] = useState('service'); + const [label, setLabel] = useState(''); + const [author, setAuthor] = useState(''); + + const load = async () => { + setLoading(true); + setError(null); + try { + const next = await getMindSpaceWechatMpConfig(); + setConfig(next); + setAppId(next.appId ?? ''); + setAccountType(next.accountType ?? 'service'); + setLabel(next.label ?? ''); + setAuthor(next.author ?? ''); + setAppSecret(''); + } catch (err) { + setError(err instanceof Error ? err.message : '加载公众号配置失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const save = async () => { + setSaving(true); + setError(null); + setNotice(null); + try { + const next = await saveMindSpaceWechatMpConfig({ + appId: appId.trim(), + appSecret: appSecret.trim() || undefined, + accountType, + label: label.trim(), + author: author.trim(), + }); + setConfig(next); + setAppSecret(''); + setNotice('配置已保存并通过连通性校验。'); + } catch (err) { + setError(err instanceof Error ? err.message : '保存公众号配置失败'); + } finally { + setSaving(false); + } + }; + + const verify = async () => { + setVerifying(true); + setError(null); + setNotice(null); + try { + const next = await verifyMindSpaceWechatMpConfig(); + setConfig(next); + setNotice('凭证校验成功。'); + } catch (err) { + setError(err instanceof Error ? err.message : '凭证校验失败'); + } finally { + setVerifying(false); + } + }; + + const remove = async () => { + if (!window.confirm('确定删除公众号配置?删除后无法一键推送到草稿箱。')) return; + setDeleting(true); + setError(null); + setNotice(null); + try { + await deleteMindSpaceWechatMpConfig(); + setConfig({ + configured: false, + appId: '', + accountType: 'service', + label: '', + author: '', + appSecretMasked: '', + verifiedAt: null, + updatedAt: null, + }); + setAppId(''); + setAppSecret(''); + setLabel(''); + setAuthor(''); + setNotice('已删除公众号配置。'); + } catch (err) { + setError(err instanceof Error ? err.message : '删除公众号配置失败'); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+
+ +

WECHAT MP

+

M 配置

+
+
+ +

+ 填写你的服务号或订阅号 AppID 与 AppSecret。保存后,可在页面发布时一键推送到公众号草稿箱。 + 请先在微信公众平台配置服务器 IP 白名单,否则无法获取 access_token。 +

+ + {loading ? ( +
正在加载配置…
+ ) : ( +
+ + + + + + + {config?.configured ? ( +
+ 最近校验:{formatTime(config.verifiedAt)} + 最近更新:{formatTime(config.updatedAt)} +
+ ) : null} + + {error ?
{error}
: null} + {notice ?
{notice}
: null} + +
+ 保存时会自动校验凭证;Secret 加密存储,不会明文回显。 +
+ {config?.configured ? ( + <> + + + + ) : null} + +
+
+
+ )} +
+ ); +} diff --git a/src/index.css b/src/index.css index e047c48..9a2f863 100644 --- a/src/index.css +++ b/src/index.css @@ -8296,6 +8296,25 @@ body, line-height: 1.6; } +.mindspace-wechat-config-intro { + margin: 0 0 18px; + color: rgba(24, 33, 29, 0.72); + font-size: 14px; + line-height: 1.6; +} + +.mindspace-wechat-config-form { + max-width: 720px; +} + +.mindspace-wechat-config-meta { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; + color: rgba(24, 33, 29, 0.62); + font-size: 13px; +} + .mindspace-achievements-bulkbar { margin-bottom: 14px; } diff --git a/src/routes/MindSpaceRoute.tsx b/src/routes/MindSpaceRoute.tsx index d52146b..cc3e841 100644 --- a/src/routes/MindSpaceRoute.tsx +++ b/src/routes/MindSpaceRoute.tsx @@ -27,6 +27,7 @@ export function MindSpaceRoute({ const [searchParams] = useSearchParams(); const pageMatch = matchPath('/space/page/:pageId', location.pathname); const achievementsMatch = matchPath('/space/achievements', location.pathname); + const wechatConfigMatch = matchPath('/space/wechat-config', location.pathname); const pageId = pageMatch?.params.pageId ?? null; const categoryCode = parseCategory(searchParams.get('category'), healthEnabled); @@ -37,12 +38,14 @@ export function MindSpaceRoute({ initialPageId={pageId} initialCategoryCode={categoryCode} initialAchievementsOpen={Boolean(achievementsMatch)} + initialWechatConfigOpen={Boolean(wechatConfigMatch)} onBack={() => navigate('/')} onLogout={onLogout} onOpenFeedback={() => navigate('/feedback')} routeSync={{ pushHome: () => navigate('/space'), pushAchievements: () => navigate('/space/achievements'), + pushWechatConfig: () => navigate('/space/wechat-config'), pushCategory: (code) => navigate(`/space?category=${code}`), pushPage: (id) => navigate(`/space/page/${id}`), }} diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index c4dc81b..deb0cbd 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -91,7 +91,7 @@ function formatShanghaiDateParts(date = new Date()) { }; } -function isDailyNewsFormat(html) { +export function isDailyNewsFormat(html) { return /daily-news|每日新闻早报/u.test(String(html ?? '')) || /class="date-badge"/u.test(String(html ?? '')); }