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(/