diff --git a/docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md b/docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md index 9148253..9e59de6 100644 --- a/docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md +++ b/docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md @@ -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/.json`。记录预期稿件、封面、media_id、首个修复前快照、校验结果和修复次数,文件权限 0600,不保存 access_token。持久目录随现有 data 备份保留。 +- 创建结果不明时,先通过草稿箱列表按完整标题和来源定位。没有查到也不盲目再创建;多份匹配或分页扫描未覆盖全部记录时暂停。扫描最多 200 条,未完成扫描不得视为“草稿不存在”。旧 ID 返回 40007 时可重新绑定唯一匹配稿;若无对应稿,可能已发布或移除,转为待核查,不自动重建。 +- 正文、图片或封面不匹配时更新同一个 media_id,并再次读回。每轮最多补救两次,同一投递记录累计最多四次;请求超时先读回确认,不重复创建。耗尽补救次数时写 attention_required,并停止本轮定时尝试。 +- 读取暂不可用时最多立即读三次;跨轮重试使用已有记录和已上传素材,不再次上传图片。进程内合并重复请求,进程间使用排他文件锁;死进程锁可回收。服务重启后从持久记录继续。 +- 只有唯一身份一致的稿件可修复。标题或来源被修改时禁止覆盖;不主动删除重复稿。仍有歧义时需要人工核查,不宣称所有异常都能自动恢复。 + +新增投递读回与补救测试后,相关回归共 108 项通过。包括真实服务入口读回失败后接续且不重复上传素材、跨次调用创建超时接续、并发/死进程锁、分页不足不创建、丢图及更新超时补救、照片裁剪、四次上限、失效 ID 重新定位及已移除稿不重建。 + +本轮只读生产检查返回旧草稿 ID 40007,草稿箱列表未找到同来源的 10 月 21 日稿件;无法区分已发布和已删除,本轮未重建或修改任何生产草稿,不能把该检查记成生产端新机制验收成功。本次自动化代码仍未发布。 diff --git a/wechat-news-morning-delivery-verification.mjs b/wechat-news-morning-delivery-verification.mjs new file mode 100644 index 0000000..720e921 --- /dev/null +++ b/wechat-news-morning-delivery-verification.mjs @@ -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(/]*>/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 }; +} diff --git a/wechat-news-morning-delivery-verification.test.mjs b/wechat-news-morning-delivery-verification.test.mjs new file mode 100644 index 0000000..7f2dbfd --- /dev/null +++ b/wechat-news-morning-delivery-verification.test.mjs @@ -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:'

新闻正文

',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='

新闻正文

'; + 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('

新闻正文

','

新闻正文

')}; + assert.ok(Object.values(compareNewsMorningDraft(actual,article)).every(Boolean)); + actual.content='

新闻正文

'; + 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:'

新闻正文

'}}); + 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();} +}); diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index 4c502b3..c57035e 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -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', { diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index fe7e7dd..284b973 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -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 } : {}) }); diff --git a/wechat-news-morning-image-recovery.test.mjs b/wechat-news-morning-image-recovery.test.mjs index 57e912c..0faa85f 100644 --- a/wechat-news-morning-image-recovery.test.mjs +++ b/wechat-news-morning-image-recovery.test.mjs @@ -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});}