fix: verify stored news drafts and recover delivery safely
Memind CI / Test, build, and release guards (push) Has been cancelled
Memind CI / Test, build, and release guards (push) Has been cancelled
This commit is contained in:
@@ -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(/&/g, '&').replace(/"/g, '"')
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/'|'/g, "'").replace(/ | /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();}
|
||||
});
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 } : {}) });
|
||||
|
||||
@@ -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});}
|
||||
|
||||
Reference in New Issue
Block a user