fix: verify stored news drafts and recover delivery safely
Memind CI / Test, build, and release guards (push) Has been cancelled

This commit is contained in:
john
2026-10-21 14:28:49 +08:00
parent 101609b3df
commit 86a2d1e0f1
6 changed files with 515 additions and 20 deletions
@@ -23,3 +23,16 @@
## 范围与回滚
无数据库 schema 或配置变更。回滚本次提交即恢复旧流程;已生成的页面内容和草稿不回滚。当天内容修复备份在生产 `data/news-image-repair-1792532762135/`,本地验收在 `/tmp/memind-news-1021-repair/`。原工作区改动单独暂存,禁止混入本次提交或发布。
## 推送后校验与补救
- 微信 `draft/add` 返回成功后,必须调用 `draft/get` 读回正文文字、图片 URL 与顺序、标题/作者/摘要/来源/评论设置及封面,全部通过才记录 success。允许微信把 `0` 图换成 `640` 图、包装 span 或清空封面 ID 后提供裁剪 URL;裁剪封面按原照片像素核验,不能只看 URL 非空。
- 创建意图在请求发出前原子写入 `data/news-morning-delivery/<source-url-sha256>.json`。记录预期稿件、封面、media_id、首个修复前快照、校验结果和修复次数,文件权限 0600,不保存 access_token。持久目录随现有 data 备份保留。
- 创建结果不明时,先通过草稿箱列表按完整标题和来源定位。没有查到也不盲目再创建;多份匹配或分页扫描未覆盖全部记录时暂停。扫描最多 200 条,未完成扫描不得视为“草稿不存在”。旧 ID 返回 40007 时可重新绑定唯一匹配稿;若无对应稿,可能已发布或移除,转为待核查,不自动重建。
- 正文、图片或封面不匹配时更新同一个 media_id,并再次读回。每轮最多补救两次,同一投递记录累计最多四次;请求超时先读回确认,不重复创建。耗尽补救次数时写 attention_required,并停止本轮定时尝试。
- 读取暂不可用时最多立即读三次;跨轮重试使用已有记录和已上传素材,不再次上传图片。进程内合并重复请求,进程间使用排他文件锁;死进程锁可回收。服务重启后从持久记录继续。
- 只有唯一身份一致的稿件可修复。标题或来源被修改时禁止覆盖;不主动删除重复稿。仍有歧义时需要人工核查,不宣称所有异常都能自动恢复。
新增投递读回与补救测试后,相关回归共 108 项通过。包括真实服务入口读回失败后接续且不重复上传素材、跨次调用创建超时接续、并发/死进程锁、分页不足不创建、丢图及更新超时补救、照片裁剪、四次上限、失效 ID 重新定位及已移除稿不重建。
本轮只读生产检查返回旧草稿 ID 40007,草稿箱列表未找到同来源的 10 月 21 日稿件;无法区分已发布和已删除,本轮未重建或修改任何生产草稿,不能把该检查记成生产端新机制验收成功。本次自动化代码仍未发布。
@@ -0,0 +1,232 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import sharp from 'sharp';
const active = new Map();
const decode = value => String(value ?? '').replace(/&amp;/g, '&').replace(/&quot;/g, '"')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&apos;|&#39;/g, "'").replace(/&nbsp;|&#160;/g, ' ')
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n);
return code <= 0x10ffff ? String.fromCodePoint(code) : '';
});
const text = html => decode(String(html ?? '').replace(/<[^>]*>/g, '')).replace(/\s/g, '');
const pendingError = message => Object.assign(new Error(message), { code: 'NEWS_MORNING_DELIVERY_PENDING' });
export function readNewsMorningDeliveryIntent(stateDir, sourceUrl) {
const file = path.join(stateDir, createHash('sha256').update(sourceUrl || '').digest('hex') + '.json');
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
}
function imageRefs(html) {
return [...String(html ?? '').matchAll(/<img\b[^>]*>/gi)].map(([tag]) => {
const ref = tag.match(/\bdata-src=["']([^"']+)/i)?.[1] || tag.match(/\bsrc=["']([^"']+)/i)?.[1] || '';
try {
const u = new URL(decode(ref));
if (/(?:^|\.)qpic\.cn$/.test(u.hostname)) {
u.protocol = 'https:';
u.pathname = u.pathname.replace(/\/(?:0|640)$/, '/size');
u.search = '';
}
return u.href;
} catch { return ref; }
});
}
export function compareNewsMorningDraft(actual, expected) {
return {
textMatches: text(actual?.content) === text(expected.content),
imagesMatch: JSON.stringify(imageRefs(actual?.content)) === JSON.stringify(imageRefs(expected.content)),
metadataMatches: ['title', 'author', 'digest', 'content_source_url'].every(key => decode(actual?.[key]) === decode(expected[key]))
&& ['need_open_comment', 'only_fans_can_comment'].every(key => Number(actual?.[key] ?? 0) === Number(expected[key] ?? 0)),
coverMatches: Boolean(actual?.thumb_media_id) && actual.thumb_media_id === expected.thumb_media_id,
};
}
async function coverPhotoMatches(url, expectedBuffer, fetchImpl) {
if (!url || !expectedBuffer) return false;
try {
const u = new URL(url);
if (!/(?:^|\.)qpic\.cn$/.test(u.hostname) || !['https:', 'http:'].includes(u.protocol)) return false;
u.protocol = 'https:';
const response = await fetchImpl(u.href, { signal: AbortSignal.timeout(12000) });
if (!response.ok) return false;
const bytes = Buffer.from(await response.arrayBuffer());
const meta = await sharp(bytes, { limitInputPixels: 20_000_000 }).metadata();
const expected = await sharp(expectedBuffer).resize(meta.width, meta.height, { fit: 'cover' }).toBuffer();
const [a, b] = await Promise.all([bytes, expected].map(input => sharp(input).resize(32, 16, { fit: 'fill' }).removeAlpha().toColourspace('srgb').raw().toBuffer()));
return a.length === b.length && a.reduce((sum, v, i) => sum + (v - b[i]) ** 2, 0) / a.length < 120;
} catch { return false; }
}
export async function verifyNewsMorningDraftReadback(actual, expected, { coverBuffer, fetchImpl = fetch } = {}) {
const checks = compareNewsMorningDraft(actual, expected);
if (!checks.coverMatches) checks.coverMatches = await coverPhotoMatches(actual?.thumb_url, coverBuffer, fetchImpl);
return checks;
}
/** Durable intent before draft/add, then readback and same-ID repairs. */
export async function deliverVerifiedNewsMorningDraft(options) {
if (!options.article.content_source_url) throw pendingError('缺少草稿来源地址,不能建立可靠的去重记录。');
const key = createHash('sha256').update(options.article.content_source_url || '').digest('hex');
const file = path.join(options.stateDir, key + '.json');
if (active.has(file)) return active.get(file);
const promise = (async () => {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const lock = file + '.lock';
let fd;
try {
try { fd = fs.openSync(lock, 'wx', 0o600); }
catch (error) {
if (error.code !== 'EEXIST') throw error;
let reaper;
try { reaper = fs.openSync(lock + '.reap', 'wx', 0o600); }
catch { throw pendingError('另一进程正在恢复投递锁,稍后重试。'); }
try {
let owner;
try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { throw pendingError('投递锁尚未就绪,稍后重试。'); }
try { process.kill(owner.pid, 0); throw pendingError('同一草稿正在由另一进程投递。'); }
catch (probe) { if (probe.code !== 'ESRCH') throw probe; }
fs.unlinkSync(lock);
fd = fs.openSync(lock, 'wx', 0o600);
} finally { fs.closeSync(reaper); fs.unlinkSync(lock + '.reap'); }
}
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
return await deliver({ ...options, file });
} catch (error) {
try { error.draftMediaId = JSON.parse(fs.readFileSync(file, 'utf8')).mediaId ?? null; } catch {}
throw error;
} finally {
if (fd != null) { fs.closeSync(fd); fs.unlinkSync(lock); }
}
})();
active.set(file, promise);
try { return await promise; } finally { active.delete(file); }
}
async function deliver({ file, accessToken, article, coverBuffer, wechatFetch, wait = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
let state = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
const save = () => {
state.updatedAt = Date.now();
const temp = file + '.' + randomUUID();
fs.writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
fs.renameSync(temp, file);
};
const attention = message => {
state.stage = 'attention_required'; state.lastError = message; save();
return Object.assign(pendingError(message), { code: 'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED' });
};
async function api(route, data) {
const response = await wechatFetch(`https://api.weixin.qq.com/cgi-bin/${route}?access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(12000),
});
const result = await response.json();
if (!response.ok || result.errcode) throw Object.assign(new Error(`WeChat ${route}: ${result.errmsg || response.status}`), { rejected: response.ok && Boolean(result.errcode), wechatCode: Number(result.errcode ?? 0) });
return result;
}
async function findExisting() {
const found = [];
let exhausted = false;
for (let offset = 0; offset < 200; offset += 20) {
const batch = await api('draft/batchget', { offset, count: 20, no_content: 0 });
const items = batch.item ?? [];
for (const item of items) {
const articles = item.content?.news_item ?? [];
if (articles.length === 1 && articles[0].content_source_url === state.article.content_source_url && articles[0].title === state.article.title) found.push(item.media_id);
}
if (items.length < 20 || (batch.total_count != null && offset + items.length >= batch.total_count)) { exhausted = true; break; }
}
if (!exhausted) throw pendingError('草稿箱扫描尚未覆盖全部记录,暂停创建,避免遗漏已有草稿。');
if (found.length > 1) throw attention('发现多份同题同来源草稿,暂停创建或修改,需核对重复草稿。');
return found[0] || null;
}
if (state) {
if (state.article.content_source_url !== article.content_source_url || state.article.title !== article.title) throw pendingError('草稿身份与既有投递记录不一致。');
if (text(state.article.content) !== text(article.content)) {
if (state.stage !== 'verified') throw pendingError('待验收草稿的正文版本已变化,保留原投递记录并暂停重建。');
state.previousArticle = state.article;
state.article = article;
state.coverBase64 = coverBuffer.toString('base64');
state.stage = 'created';
save();
}
} else {
state = { version: 1, stage: 'discovering', createdAt: Date.now(), article, coverBase64: coverBuffer.toString('base64'), repairs: 0 };
save();
}
if (!state.mediaId) {
const existing = await findExisting();
if (existing) { state.mediaId = existing; state.stage = 'created'; save(); }
else if (state.stage === 'creating') throw pendingError('上次创建草稿结果不明,尚未在草稿箱查到;保留投递意图并等待重查,禁止重复创建。');
else if (state.stage === 'attention_required') throw attention('既有投递记录需要核查,且未找到可接续的草稿,禁止自动重建。');
else {
state.stage = 'creating'; save();
try {
const created = await api('draft/add', { articles: [state.article] });
if (!created.media_id) throw pendingError('创建响应缺少 media_id,需核对草稿箱。');
state.mediaId = created.media_id; state.stage = 'created'; save();
} catch (error) {
if (error.rejected) { state.stage = 'rejected'; state.lastError = error.message; save(); }
throw pendingError(error.message);
}
}
}
const expected = state.article;
async function inspect() {
let lastError;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const payload = await api('draft/get', { media_id: state.mediaId });
const actual = payload.news_item?.[0];
if (payload.news_item?.length !== 1 || actual.content_source_url !== expected.content_source_url || actual.title !== expected.title) throw pendingError('微信读回的草稿身份发生变化,禁止覆盖其他稿件。');
const checks = await verifyNewsMorningDraftReadback(actual, expected, {
coverBuffer: Buffer.from(state.coverBase64, 'base64'), fetchImpl: wechatFetch,
});
return { actual, checks, ok: Object.values(checks).every(Boolean) };
} catch (error) {
if (error.code === 'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED') throw error;
if (error.code === 'NEWS_MORNING_DELIVERY_PENDING') throw error;
if (error.wechatCode === 40007) {
let replacement;
try { replacement = await findExisting(); }
catch (lookupError) {
if (lookupError.code === 'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED') throw lookupError;
throw pendingError('旧草稿 ID 失效,重新定位暂不可用,保留记录等待重查。');
}
if (!replacement) throw attention('旧草稿 ID 已失效,草稿箱未找到同题同来源稿,可能已发布或移除;禁止擅自重建。');
if (replacement !== state.mediaId) {
state.previousMediaIds = [...(state.previousMediaIds ?? []), state.mediaId].slice(-5);
state.mediaId = replacement; state.stage = 'created'; save();
}
}
lastError = error;
if (attempt < 2) await wait(200 * (attempt + 1));
}
}
throw pendingError('微信草稿读回暂不可用,保留同一 media_id 重试:' + lastError.message);
}
let result = await inspect();
for (let attempt = 0; !result.ok && attempt < 2 && state.repairs < 4; attempt++) {
state.firstReadback ??= result.actual;
state.lastReadback = result.actual; state.checks = result.checks; state.stage = 'repairing';
state.repairs += 1; save();
// An update timeout is ambiguous too: inspect first instead of creating anew.
try { await api('draft/update', { media_id: state.mediaId, index: 0, articles: expected }); }
catch (error) { state.lastError = error.message; save(); }
result = await inspect();
}
state.checks = result.checks;
state.stage = result.ok ? 'verified' : state.repairs >= 4 ? 'attention_required' : 'repairing';
state.verifiedAt = result.ok ? Date.now() : null;
save();
if (!result.ok) {
const error = pendingError('微信正文、图片或封面验收未通过;已保留原稿快照并对同一草稿补救。' +
(state.stage === 'attention_required' ? '已达到四次自动补救上限,需人工核查。' : '等待后续重试。'));
if (state.stage === 'attention_required') error.code = 'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED';
throw error;
}
return { draftMediaId: state.mediaId, deliveredArticle: expected,
verification: { ...result.checks, repairs: state.repairs, verifiedAt: state.verifiedAt }, receiptPath: file };
}
@@ -0,0 +1,209 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {createHash} from 'node:crypto';
import sharp from 'sharp';
import {deliverVerifiedNewsMorningDraft,compareNewsMorningDraft} from './wechat-news-morning-delivery-verification.mjs';
const article={title:'每日新闻早报 · 2026年10月22日',author:'TKMind',digest:'今日要闻',content:'<p>新闻正文</p><img src="https://mmbiz.qpic.cn/news/story/0?wx_fmt=jpeg"><img src="https://mmbiz.qpic.cn/news/qr/0">',content_source_url:'https://m.tkmind.cn/MindSpace/user/public/daily-news-1022.html',thumb_media_id:'cover-real',need_open_comment:1,only_fans_can_comment:0};
const cloned=x=>JSON.parse(JSON.stringify(x));
function harness(options={}){
const root=fs.mkdtempSync(path.join(os.tmpdir(),'news-delivery-'));
const calls={add:0,get:0,update:0,list:0};let actual=options.existing?cloned(options.existing):null;
const mock=async(url,opts={})=>{
const u=String(url),body=JSON.parse(opts.body||'{}');
if(u.includes('qpic.cn/'))return new Response(options.coverBytes);
if(u.includes('draft/batchget')){
calls.list++;
return new Response(JSON.stringify({item:actual?[{media_id:'draft-one',content:{news_item:[actual]}},...(options.duplicates?[{media_id:'draft-two',content:{news_item:[actual]}}]:[])]:[],total_count:actual?(options.duplicates?2:1):0}));
}
if(u.includes('draft/add')){
calls.add++;
if(options.rejectOnce&&calls.add===1)return new Response(JSON.stringify({errcode:40001,errmsg:'invalid credential'}));
actual=cloned(body.articles[0]);
if(options.dropImage)actual.content='<p>新闻正文</p><img src="https://mmbiz.qpic.cn/news/qr/0">';
if(options.croppedCover){actual.thumb_media_id='';actual.thumb_url='http://mmecoa.qpic.cn/cover/0';}
if(options.createTimeout)throw Error('network timeout after server accepted');
return new Response(JSON.stringify({media_id:'draft-one'}));
}
if(u.includes('draft/get')){
calls.get++;
if(calls.get<=(options.readFailures||0))throw Error('temporary readback outage');
return new Response(JSON.stringify({news_item:[actual]}));
}
if(u.includes('draft/update')){
calls.update++;assert.equal(body.media_id,'draft-one');
if(!options.permanentMismatch)actual=cloned(body.articles);
if(options.updateTimeout)throw Error('network timeout after update accepted');
return new Response(JSON.stringify({errcode:0}));
}
throw Error('unexpected URL '+u);
};
const args={stateDir:root,accessToken:'test-token',article,coverBuffer:Buffer.from('cover'),wechatFetch:mock,wait:async()=>{}};
return {root,calls,args,actual:()=>actual,cleanup:()=>fs.rmSync(root,{recursive:true,force:true}),state:()=>JSON.parse(fs.readFileSync(path.join(root,createHash('sha256').update(article.content_source_url).digest('hex')+'.json')))};
}
test('readback comparison tolerates WeChat CDN size and markup changes, but detects missing photos',()=>{
const actual={...article,content:article.content.replace('/story/0?wx_fmt=jpeg','/story/640').replace('<p>新闻正文</p>','<p><span leaf="">新闻正文</span></p>')};
assert.ok(Object.values(compareNewsMorningDraft(actual,article)).every(Boolean));
actual.content='<p>新闻正文</p>';
assert.equal(compareNewsMorningDraft(actual,article).imagesMatch,false);
});
test('success is returned only after draft/get validates the actual stored draft',async()=>{
const h=harness();try{
const result=await deliverVerifiedNewsMorningDraft(h.args);
assert.equal(result.draftMediaId,'draft-one');assert.equal(h.calls.get,1);
assert.equal(h.state().stage,'verified');assert.ok(result.verification.imagesMatch);
await deliverVerifiedNewsMorningDraft(h.args);
assert.equal(h.calls.add,1,'a subsequent delivery reuses the persisted draft ID');
}finally{h.cleanup();}
});
test('a silently dropped photo is repaired on the same draft, with a pre-repair snapshot',async()=>{
const h=harness({dropImage:true});try{
const result=await deliverVerifiedNewsMorningDraft(h.args);
assert.equal(h.calls.add,1);assert.equal(h.calls.update,1);
assert.ok(result.verification.imagesMatch);assert.equal(result.verification.repairs,1);
assert.notEqual(h.state().firstReadback.content,article.content);
}finally{h.cleanup();}
});
test('an update timeout is reconciled by readback rather than a second create',async()=>{
const h=harness({dropImage:true,updateTimeout:true});try{
const result=await deliverVerifiedNewsMorningDraft(h.args);
assert.equal(result.draftMediaId,'draft-one');assert.equal(h.calls.add,1);assert.equal(h.calls.update,1);
}finally{h.cleanup();}
});
test('ambiguous create timeout is recovered from draft/batchget across a later invocation',async()=>{
const h=harness({createTimeout:true});try{
await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING'});
assert.equal(h.state().stage,'creating');
const result=await deliverVerifiedNewsMorningDraft(h.args);
assert.equal(result.draftMediaId,'draft-one');assert.equal(h.calls.add,1);
}finally{h.cleanup();}
});
test('an unresolved creation intent never causes another draft/add',async()=>{
const h=harness();try{
const file=path.join(h.root,createHash('sha256').update(article.content_source_url).digest('hex')+'.json');
fs.writeFileSync(file,JSON.stringify({stage:'creating',article,coverBase64:'YQ==',repairs:0}));
await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING'});
assert.equal(h.calls.add,0);
}finally{h.cleanup();}
});
test('temporary readback failures retry; prolonged failure preserves the media ID for the next run',async()=>{
const h=harness({readFailures:3});try{
await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING',draftMediaId:'draft-one'});
assert.equal(h.state().mediaId,'draft-one');assert.equal(h.calls.get,3);
await deliverVerifiedNewsMorningDraft(h.args);assert.equal(h.calls.add,1);
}finally{h.cleanup();}
});
test('existing same-day drafts are adopted, while duplicate matches block unsafe selection',async()=>{
const h=harness({existing:{...article,content:'<p>新闻正文</p>'}});
try{await deliverVerifiedNewsMorningDraft(h.args);assert.equal(h.calls.add,0);assert.equal(h.calls.update,1);}finally{h.cleanup();}
const d=harness({existing:article,duplicates:true});
try{
await assert.rejects(deliverVerifiedNewsMorningDraft(d.args),{code:'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED'});
const oldFetch=d.args.wechatFetch;
d.args.wechatFetch=async(url,opts)=>String(url).includes('draft/batchget')?new Response(JSON.stringify({item:[],total_count:0})):oldFetch(url,opts);
await assert.rejects(deliverVerifiedNewsMorningDraft(d.args),{code:'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED'});
assert.equal(d.calls.add,0);assert.equal(d.calls.update,0);
}finally{d.cleanup();}
});
test('unrepairable content has a durable four-update budget and never returns success',async()=>{
const h=harness({dropImage:true,permanentMismatch:true});try{
await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING'});
for(let i=0;i<2;i++)await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED'});
assert.equal(h.state().stage,'attention_required');
assert.equal(h.calls.update,4);assert.equal(h.calls.add,1);assert.equal(h.state().verifiedAt,null);
}finally{h.cleanup();}
});
test('parallel calls share one delivery and create one draft',async()=>{
const h=harness();try{
const results=await Promise.all([deliverVerifiedNewsMorningDraft(h.args),deliverVerifiedNewsMorningDraft(h.args)]);
assert.equal(h.calls.add,1);assert.equal(results[0].draftMediaId,results[1].draftMediaId);
}finally{h.cleanup();}
});
test('cropped cover URLs are verified by photo pixels when WeChat clears thumb_media_id',async()=>{
const cover=await sharp({create:{width:120,height:120,channels:3,background:'#234567'}}).jpeg().toBuffer();
const h=harness({croppedCover:true,coverBytes:cover});h.args.coverBuffer=cover;
try{const result=await deliverVerifiedNewsMorningDraft(h.args);assert.ok(result.verification.coverMatches);assert.equal(h.calls.update,0);}finally{h.cleanup();}
});
test('explicit create rejection may retry after correction, without treating rejection as a confirmed create',async()=>{
const h=harness({rejectOnce:true});try{
await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING'});
assert.equal(h.state().stage,'rejected');
await deliverVerifiedNewsMorningDraft(h.args);assert.equal(h.calls.add,2);assert.equal(h.state().stage,'verified');
}finally{h.cleanup();}
});
test('incomplete draft-box pagination blocks creation instead of claiming the draft is absent',async()=>{
const h=harness();const original=h.args.wechatFetch;
h.args.wechatFetch=async(url,opts)=>String(url).includes('draft/batchget')
?new Response(JSON.stringify({item:Array.from({length:20},(_,i)=>({media_id:'other-'+i,content:{news_item:[{title:'其他草稿',content_source_url:'https://other.example/'}]}})),total_count:201}))
:original(url,opts);
try{await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING'});assert.equal(h.calls.add,0);}finally{h.cleanup();}
});
test('identity changes in an already selected draft stop automatic overwrites',async()=>{
const h=harness();const original=h.args.wechatFetch;
h.args.wechatFetch=async(url,opts)=>String(url).includes('draft/get')
?new Response(JSON.stringify({news_item:[{...article,title:'用户修改后的其他标题'}]})):original(url,opts);
try{await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING'});assert.equal(h.calls.update,0);}finally{h.cleanup();}
});
test('live process locks block a second owner; dead process locks are recovered safely',async()=>{
const h=harness();
const file=path.join(h.root,createHash('sha256').update(article.content_source_url).digest('hex')+'.json.lock');
try{
fs.writeFileSync(file,JSON.stringify({pid:process.pid}));
await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_PENDING'});
assert.equal(h.calls.add,0);assert.ok(fs.existsSync(file));
fs.writeFileSync(file,JSON.stringify({pid:2147483647}));
const result=await deliverVerifiedNewsMorningDraft(h.args);assert.equal(result.draftMediaId,'draft-one');assert.ok(!fs.existsSync(file));
const receiptFile=file.replace(/\.lock$/,'');
assert.equal(fs.statSync(receiptFile).mode&0o777,0o600);
assert.ok(!fs.readFileSync(receiptFile,'utf8').includes('test-token'));
}finally{h.cleanup();}
});
test('invalid media IDs rebind to a uniquely matched existing draft instead of creating another',async()=>{
const h=harness();
try{
await deliverVerifiedNewsMorningDraft(h.args);
const original=h.args.wechatFetch;
h.args.wechatFetch=async(url,opts)=>{
const u=String(url),body=JSON.parse(opts.body||'{}');
if(u.includes('draft/get')&&body.media_id==='draft-one')return new Response(JSON.stringify({errcode:40007,errmsg:'invalid media_id'}));
if(u.includes('draft/batchget'))return new Response(JSON.stringify({item:[{media_id:'draft-rebound',content:{news_item:[article]}}],total_count:1}));
if(u.includes('draft/get'))return new Response(JSON.stringify({news_item:[article]}));
return original(url,opts);
};
const result=await deliverVerifiedNewsMorningDraft(h.args);
assert.equal(result.draftMediaId,'draft-rebound');assert.equal(h.calls.add,1);
assert.deepEqual(h.state().previousMediaIds,['draft-one']);
}finally{h.cleanup();}
});
test('a removed or published draft is flagged for attention and never recreated automatically',async()=>{
const h=harness();
try{
await deliverVerifiedNewsMorningDraft(h.args);
const original=h.args.wechatFetch;
h.args.wechatFetch=async(url,opts)=>String(url).includes('draft/get')
?new Response(JSON.stringify({errcode:40007,errmsg:'invalid media_id'}))
:String(url).includes('draft/batchget')?new Response(JSON.stringify({item:[],total_count:0})):original(url,opts);
await assert.rejects(deliverVerifiedNewsMorningDraft(h.args),{code:'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED'});
assert.equal(h.calls.add,1);assert.equal(h.state().stage,'attention_required');
}finally{h.cleanup();}
});
+2 -1
View File
@@ -651,7 +651,8 @@ export function startWechatNewsMorningDraftWorker({
result = await wechatNewsMorningDraftService.pushDraft({ triggeredBy: 'auto-push', now });
nextPhotoRetryAt = 0;
} catch (error) {
if (error.code === 'NEWS_MORNING_IMAGES_INCOMPLETE') nextPhotoRetryAt = now + 120_000;
if (['NEWS_MORNING_IMAGES_INCOMPLETE', 'NEWS_MORNING_DELIVERY_PENDING'].includes(error.code)) nextPhotoRetryAt = now + 120_000;
if (error.code === 'NEWS_MORNING_DELIVERY_ATTENTION_REQUIRED') nextPhotoRetryAt = now + 1800_000;
throw error;
}
logger.log?.('[NewsMorningDraft] auto push succeeded', {
+41 -15
View File
@@ -24,6 +24,7 @@ import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mj
import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs';
import { recoverNewsMorningPhotos, assertNewsMorningPhotoCoverage } from './wechat-news-morning-image-recovery.mjs';
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
import { deliverVerifiedNewsMorningDraft, readNewsMorningDeliveryIntent } from './wechat-news-morning-delivery-verification.mjs';
import { maskBlockedNewsTerms } from './news-content-safety-rules.mjs';
import {
NEWS_MORNING_AI_DISCLOSURE_TEXT,
@@ -1629,6 +1630,24 @@ export function createWechatNewsMorningDraftService(
}
try {
const accessToken = await getAccessToken();
const deliveryStateDir = path.join(h5Root, 'data', 'news-morning-delivery');
const previous = config.templateId === 'news002'
? readNewsMorningDeliveryIntent(deliveryStateDir, preview.page.publicUrl) : null;
if (previous && previous.stage !== 'verified' && !articleTitleOverride
&& previous.article.title === preview.article.title) {
const resumed = await deliverVerifiedNewsMorningDraft({
accessToken, article: previous.article,
coverBuffer: Buffer.from(previous.coverBase64, 'base64'), wechatFetch, stateDir: deliveryStateDir,
});
const run = await recordRun({ status: 'success', pageSlug: preview.page.slug,
pageUrl: preview.page.publicUrl, draftMediaId: resumed.draftMediaId, triggeredBy });
logger.log?.('[NewsMorningDraft] pending delivery verified', {
draftMediaId: resumed.draftMediaId, ...resumed.verification,
});
return { ok: true, draftMediaId: resumed.draftMediaId, verification: resumed.verification,
cover: { source: 'news-image' },
preview: { ...preview, article: { ...preview.article, content: resumed.deliveredArticle.content } }, run };
}
const page = pageOverride?.localPath
? {
slug: preview.page.slug,
@@ -1713,24 +1732,29 @@ export function createWechatNewsMorningDraftService(
}
article.title = maskBlockedNewsTerms(article.title).text;
article.digest = maskBlockedNewsTerms(article.digest ?? '').text;
const draft = await addWechatDraftArticle(
accessToken,
{
title: article.title,
author: config.author,
digest: article.digest,
content: article.content,
content_source_url: article.contentSourceUrl,
thumb_media_id: thumbMediaId,
need_open_comment: config.openComment !== false ? 1 : 0,
only_fans_can_comment: 0,
},
{ wechatFetch },
);
const deliveryArticle = {
title: article.title,
author: config.author,
digest: article.digest,
content: article.content,
content_source_url: article.contentSourceUrl,
thumb_media_id: thumbMediaId,
need_open_comment: config.openComment !== false ? 1 : 0,
only_fans_can_comment: 0,
};
const draft = config.templateId === 'news002'
? await deliverVerifiedNewsMorningDraft({
accessToken, article: deliveryArticle, coverBuffer: thumb.buffer, wechatFetch,
stateDir: deliveryStateDir,
})
: await addWechatDraftArticle(accessToken, deliveryArticle, { wechatFetch });
preview = {
...preview,
article,
article: draft.deliveredArticle ? { ...article, content: draft.deliveredArticle.content } : article,
};
if (draft.verification) logger.log?.('[NewsMorningDraft] delivery verified', {
draftMediaId: draft.draftMediaId, ...draft.verification,
});
const run = await recordRun({
status: 'success',
pageSlug: preview.page.slug,
@@ -1741,6 +1765,7 @@ export function createWechatNewsMorningDraftService(
return {
ok: true,
draftMediaId: draft.draftMediaId,
verification: draft.verification ?? null,
cover: {
source: thumb.source,
prompt: thumb.prompt ?? null,
@@ -1756,6 +1781,7 @@ export function createWechatNewsMorningDraftService(
pageSlug: preview?.page?.slug ?? null,
pageUrl: preview?.page?.publicUrl ?? null,
errorMessage: message,
draftMediaId: error?.draftMediaId ?? null,
triggeredBy,
});
throw Object.assign(new Error(message), { run, ...(error?.code ? { code: error.code } : {}) });
+18 -4
View File
@@ -95,13 +95,13 @@ for (const triggeredBy of ['auto-push','manual-push','degraded-regenerate']) {
});
}
for (const uploadWorks of [true, false]) {
test(`scheduled push ${uploadWorks ? 'delivers recovered photos and a real cover' : 'stops when WeChat photo upload fails'}`,async()=>{
for (const uploadWorks of [true, false, 'readback-outage']) {
test(`scheduled push ${uploadWorks === 'readback-outage' ? 'resumes readback without uploading again' : uploadWorks ? 'delivers recovered photos and a real cover' : 'stops when WeChat photo upload fails'}`,async()=>{
const root=fs.mkdtempSync(path.join(os.tmpdir(),'news-photo-delivery-'));
const pub=path.join(root,PUBLISH_ROOT_DIR,'user-news',PUBLIC_ZONE_DIR);
fs.mkdirSync(pub,{recursive:true});fs.writeFileSync(path.join(pub,'daily-news-1022.html'),source);
const bytes=await sharp({create:{width:180,height:120,channels:3,background:'#234567'}}).jpeg().toBuffer();
let created=null,uploads=0,recoveries=0;
let created=null,uploads=0,recoveries=0,readbacks=0;
const db=pool();
const service=createWechatNewsMorningDraftService(db,{
mpConfig:{enabled:true,appId:'app',appSecret:'secret'},h5Root:root,logger:log,
@@ -121,6 +121,12 @@ for (const uploadWorks of [true, false]) {
if(u.includes('stable_token'))return new Response(JSON.stringify({access_token:'test',expires_in:7200}));
if(u.includes('material/add_material'))return new Response(JSON.stringify({media_id:'real-cover'}));
if(u.includes('media/uploadimg'))return new Response(JSON.stringify(uploadWorks?{url:`https://mmbiz.qpic.cn/mmbiz_jpg/photo-${++uploads}/0`}:{errcode:40009,errmsg:'image rejected'}));
if(u.includes('draft/batchget'))return new Response(JSON.stringify({item:[],total_count:0}));
if(u.includes('draft/get')){
readbacks++;
if(uploadWorks === 'readback-outage' && readbacks <= 3)throw Error('temporary readback failure');
return new Response(JSON.stringify({news_item:created.articles}));
}
if(u.includes('draft/add')){created=JSON.parse(opts.body);return new Response(JSON.stringify({media_id:'draft-recovered'}));}
throw Error('Unexpected WeChat request '+u);
},
@@ -130,10 +136,18 @@ for (const uploadWorks of [true, false]) {
await assert.rejects(service.pushDraft({triggeredBy:'auto-push',now:Date.UTC(2026,9,21,22)}));
assert.equal(created,null);return;
}
let uploadsBeforeResume;
if(uploadWorks === 'readback-outage'){
await assert.rejects(service.pushDraft({triggeredBy:'auto-push',now:Date.UTC(2026,9,21,22)}),{code:'NEWS_MORNING_DELIVERY_PENDING'});
uploadsBeforeResume=uploads;
assert.ok(db.runs.some(row=>row[1]==='failed' && row[4]==='draft-recovered'));
}
const result=await service.pushDraft({triggeredBy:'auto-push',now:Date.UTC(2026,9,21,22)});
if(uploadsBeforeResume != null)assert.equal(uploads,uploadsBeforeResume);
assert.ok(result.verification.imagesMatch);
assert.equal(recoveries,1);assert.equal(result.cover.source,'news-image');
assert.equal(created.articles[0].thumb_media_id,'real-cover');
assert.ok(result.preview.article.newsImageCount>=3);
if(uploadWorks !== 'readback-outage')assert.ok(result.preview.article.newsImageCount>=3);
assert.ok(fs.existsSync(path.join(pub,'daily-news-1022.thumbnail.png')));
assert.ok(db.runs.some(row=>row[1]==='success'));
}finally{fs.rmSync(root,{recursive:true,force:true});}