From f57e080b49a8dd56f580f44c88cdca6a81a8a63b Mon Sep 17 00:00:00 2001 From: john Date: Mon, 17 Aug 2026 22:35:16 +0800 Subject: [PATCH] docs(ops): register wechat delivery fix branch and add manual resend scripts Document branch disposition after 103 release, and add standalone/manual helpers to resend scheduled-task WeChat delivery when reconcile cannot replay. Co-authored-by: Cursor --- docs/branch-disposition.md | 23 +++++ ...esend-scheduled-task-wechat-standalone.mjs | 94 +++++++++++++++++++ .../manual-resend-scheduled-task-wechat.mjs | 88 +++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 scripts/manual-resend-scheduled-task-wechat-standalone.mjs create mode 100644 scripts/manual-resend-scheduled-task-wechat.mjs diff --git a/docs/branch-disposition.md b/docs/branch-disposition.md index f06449c..049aa2f 100644 --- a/docs/branch-disposition.md +++ b/docs/branch-disposition.md @@ -3,6 +3,29 @@ 本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。 它是分支复用、合并、cherry-pick 和清理前的必查清单。 +## `feature/scheduled-task-wechat-delivery-fix` + +**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。** + +审计日期:2026-08-17 +分支 HEAD:`1ca06e58` +`origin/main` 对应提交:`1ca06e58` + +### 原始用途 + +修复唐用户定时新闻任务微信链接被 guard 误杀、页面未落盘即推送、以及 reconcile 后未补发微信的问题。 + +### 验证摘要 + +- `node --test scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs notification-dispatcher.test.mjs`:22 passed +- `node --test wechat-mp.test.mjs`:100 passed(含 verifiedHtmlUrls 用例) +- 103 快速发布绑定 `1ca06e58` + +### 最终处置 + +- 保留本地分支名用于审计追溯。 +- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。 + ## `feature/production-inspection-fixes` **状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。** diff --git a/scripts/manual-resend-scheduled-task-wechat-standalone.mjs b/scripts/manual-resend-scheduled-task-wechat-standalone.mjs new file mode 100644 index 0000000..4d896df --- /dev/null +++ b/scripts/manual-resend-scheduled-task-wechat-standalone.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Standalone one-shot WeChat resend for a completed scheduled task page. + * Designed to run on 103 with only mysql2 from runtime node_modules. + */ +import process from 'node:process'; + +const TANG = process.argv.includes('--user-id') + ? process.argv[process.argv.indexOf('--user-id') + 1] + : 'a70ff537-8908-486e-9b6c-042e07cc25db'; +const RELATIVE = process.argv.includes('--relative-path') + ? process.argv[process.argv.indexOf('--relative-path') + 1] + : 'public/daily-news-0817.html'; +const TASK_ID = '21799936-0532-420c-b144-65ea3846cde1'; + +async function getStableAccessToken(config) { + const response = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credential', + appid: config.appId, + secret: config.appSecret, + }), + }); + const payload = await response.json(); + if (!payload?.access_token) { + throw new Error(`stable_token failed: ${JSON.stringify(payload)}`); + } + return payload.access_token; +} + +async function main() { + const { default: mysql } = await import('mysql2/promise'); + const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 }); + const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID ?? process.env.WX_APP_ID; + const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET ?? process.env.WX_APP_SECRET; + if (!appId || !appSecret) throw new Error('缺少 WECHAT_MP_APP_ID / WECHAT_MP_APP_SECRET'); + + const [ident] = await pool.query( + 'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1', + [TANG, appId], + ); + const openid = ident?.[0]?.openid; + if (!openid) throw new Error('用户未绑定微信'); + + const [tasks] = await pool.query( + 'SELECT title, last_result_json FROM h5_scheduled_tasks WHERE id = ? LIMIT 1', + [TASK_ID], + ); + const task = tasks?.[0]; + const deliveryText = String(task?.last_result_json?.deliveryText ?? '').trim(); + if (!deliveryText) throw new Error('缺少 deliveryText'); + + const url = `https://m.tkmind.cn/MindSpace/${TANG}/${RELATIVE.replace(/^\/+/, '')}`; + const text = `定时任务完成:${task.title}\n\n${deliveryText.includes(url) ? deliveryText : `${deliveryText}\n\n页面链接:\n${url}`}`.trim(); + + const accessToken = await getStableAccessToken({ appId, appSecret }); + const response = await fetch(`https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${encodeURIComponent(accessToken)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + touser: openid, + msgtype: 'text', + text: { content: text.slice(0, 2048) }, + }), + }); + const payload = await response.json(); + if (Number(payload?.errcode ?? 0) !== 0) { + throw new Error(`微信发送失败 errcode=${payload?.errcode} errmsg=${payload?.errmsg}`); + } + + const now = Date.now(); + const nextResult = { + ...(task.last_result_json ?? {}), + wechatDelivery: { + sentAt: now, + relativePaths: [RELATIVE], + source: 'manual_resend', + }, + }; + await pool.query( + 'UPDATE h5_scheduled_tasks SET last_result_json = ?, updated_at = ? WHERE id = ?', + [JSON.stringify(nextResult), now, TASK_ID], + ); + + console.log(JSON.stringify({ ok: true, openid: `${openid.slice(0, 8)}...`, url }, null, 2)); + await pool.end(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/manual-resend-scheduled-task-wechat.mjs b/scripts/manual-resend-scheduled-task-wechat.mjs new file mode 100644 index 0000000..0d82e5f --- /dev/null +++ b/scripts/manual-resend-scheduled-task-wechat.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * One-shot proactive WeChat resend for a completed scheduled task page delivery. + * Usage: + * node scripts/manual-resend-scheduled-task-wechat.mjs --user-id --relative-path public/foo.html [--dry-run] + */ +import process from 'node:process'; +import mysql from 'mysql2/promise'; +import { loadH5Environment } from './load-env.mjs'; +import { + buildScheduledTaskVerifiedHtmlUrls, + formatScheduledTaskDeliveryMessage, + resendScheduledTaskWechatForReadyPage, +} from '../scheduled-task-executor.mjs'; +import { loadWechatMpConfig, createWechatMpService } from '../wechat-mp.mjs'; +import { createNotificationDispatcher } from '../notification-dispatcher.mjs'; +import { createUserAuth } from '../user-auth.mjs'; + +loadH5Environment(new URL('.', import.meta.url).pathname); + +function parseArgs(argv) { + const args = { + userId: '', + relativePath: '', + dryRun: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--user-id') args.userId = String(argv[++i] ?? '').trim(); + else if (token === '--relative-path') args.relativePath = String(argv[++i] ?? '').trim(); + else if (token === '--dry-run') args.dryRun = true; + } + return args; +} + +async function main() { + const { userId, relativePath, dryRun } = parseArgs(process.argv.slice(2)); + if (!userId || !relativePath) { + throw new Error('用法: node scripts/manual-resend-scheduled-task-wechat.mjs --user-id --relative-path public/foo.html'); + } + if (!process.env.DATABASE_URL) { + throw new Error('缺少 DATABASE_URL'); + } + + const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 }); + const userAuth = createUserAuth(pool); + const wechatMpService = createWechatMpService({ + config: loadWechatMpConfig(), + userAuth, + apiFetch: async () => { + throw new Error('manual resend does not proxy chat sessions'); + }, + mysqlPool: pool, + }); + const notificationDispatcher = createNotificationDispatcher({ + sendWechatTextToUser: wechatMpService?.enabled + ? (targetUserId, text, options) => wechatMpService.sendTextToUser(targetUserId, text, options) + : null, + }); + + if (dryRun) { + const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(userId, [relativePath]); + console.log(JSON.stringify({ mode: 'dry-run', userId, relativePath, verifiedHtmlUrls }, null, 2)); + await pool.end(); + return; + } + + const sent = await resendScheduledTaskWechatForReadyPage({ + pool, + userId, + relativePath, + notificationDispatcher, + logger: console, + }); + console.log(JSON.stringify({ + mode: 'apply', + userId, + relativePath, + sent, + }, null, 2)); + await pool.end(); + if (!sent) process.exitCode = 1; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});