From c36558e0f32f6aa67d39a76542ad18d711785784 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 22 Sep 2026 16:30:40 +0800 Subject: [PATCH] feat(wechat): keep morning/weather push for suspended billing users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 欠费停用用户仍送达早安与天气,跳过复杂页面订阅与定时任务补发。 Co-authored-by: Cursor --- schedule-reminder-worker.mjs | 43 ++++++- schedule-reminder-worker.test.mjs | 140 +++++++++++++++++++++ schedule-service.mjs | 17 +++ scheduled-task-executor.mjs | 13 ++ scheduled-task-executor.test.mjs | 26 ++++ wechat/handlers/push-subscription.mjs | 13 ++ wechat/suspended-user-push-policy.mjs | 49 ++++++++ wechat/suspended-user-push-policy.test.mjs | 91 ++++++++++++++ 8 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 wechat/suspended-user-push-policy.mjs create mode 100644 wechat/suspended-user-push-policy.test.mjs diff --git a/schedule-reminder-worker.mjs b/schedule-reminder-worker.mjs index ddf9e25..91722ee 100644 --- a/schedule-reminder-worker.mjs +++ b/schedule-reminder-worker.mjs @@ -1,4 +1,9 @@ import { deliverWechatScheduleNotification } from './notification-dispatcher.mjs'; +import { + isSuspendedBillingUser, + sanitizeDeliveryForSuspendedUser, + shouldSkipPushReminderForSuspendedUser, +} from './wechat/suspended-user-push-policy.mjs'; export function startScheduleReminderWorker({ scheduleService, @@ -30,9 +35,33 @@ export function startScheduleReminderWorker({ const reminder = await scheduleService.lockReminder(candidate.id); if (!reminder) continue; try { - const delivery = typeof scheduleService.buildReminderDelivery === 'function' + const userStatus = await scheduleService.getUserStatusSnapshot?.(reminder.userId); + const pushSource = await scheduleService.getReminderPushSource?.(reminder); + if ( + shouldSkipPushReminderForSuspendedUser({ + userStatus, + pushSource, + }) + ) { + await scheduleService.logDelivery({ + reminderId: reminder.id, + userId: reminder.userId, + channel: reminder.channel, + status: 'skipped', + errorMessage: '欠费停用:已跳过非基础推送', + }).catch(() => {}); + await scheduleService.markReminderSent(reminder); + await scheduleService.scheduleNextDailyReminder?.(reminder); + continue; + } + + const rawDelivery = typeof scheduleService.buildReminderDelivery === 'function' ? await scheduleService.buildReminderDelivery(reminder) : { text: await scheduleService.buildReminderText(reminder), verifiedHtmlUrls: [] }; + const delivery = sanitizeDeliveryForSuspendedUser(rawDelivery, { + userStatus, + pushSource, + }); const text = delivery?.text ?? null; const verifiedHtmlUrls = Array.isArray(delivery?.verifiedHtmlUrls) ? delivery.verifiedHtmlUrls @@ -85,6 +114,18 @@ export function startScheduleReminderWorker({ const subscription = await scheduleService.lockDigestSubscription(candidate.id); if (!subscription) continue; try { + const userStatus = await scheduleService.getUserStatusSnapshot?.(subscription.userId); + if (isSuspendedBillingUser(userStatus)) { + await scheduleService.logDelivery({ + subscriptionId: subscription.id, + userId: subscription.userId, + channel: subscription.channel, + status: 'skipped', + errorMessage: '欠费停用:已跳过待办摘要', + }).catch(() => {}); + await scheduleService.markDigestSent(subscription); + continue; + } const text = await scheduleService.buildTodoDigestText({ userId: subscription.userId, timezone: subscription.timezone, diff --git a/schedule-reminder-worker.test.mjs b/schedule-reminder-worker.test.mjs index 75ef95e..a414fc6 100644 --- a/schedule-reminder-worker.test.mjs +++ b/schedule-reminder-worker.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; +import { pushSubscriptionMetadataSource } from './wechat/push-subscription-catalog.mjs'; test('schedule reminder worker sends due daily todo digest', async () => { const calls = []; @@ -265,6 +266,145 @@ test('schedule reminder worker skips wechat for in_app reminders', async () => { assert.deepEqual(sent, []); }); +test('schedule reminder worker skips nonessential push subscriptions for suspended users', async () => { + const sent = []; + const calls = []; + const reminder = { + id: 'rem-news', + userId: 'user-suspended', + itemId: 'item-news', + remindAt: Date.now() - 1000, + channel: 'wechat', + attempts: 1, + }; + const worker = startScheduleReminderWorker({ + intervalMs: 60_000, + scheduleService: { + async listDueReminders() { + return [reminder]; + }, + async lockReminder() { + return reminder; + }, + async getUserStatusSnapshot() { + return 'suspended'; + }, + async getReminderPushSource() { + return pushSubscriptionMetadataSource('news'); + }, + async buildReminderDelivery() { + return { + text: '📰 每日新闻早报\n\n链接 https://m.tkmind.cn/page.html', + verifiedHtmlUrls: ['https://m.tkmind.cn/page.html'], + }; + }, + async logDelivery(input) { + calls.push(`log:${input.status}:${input.errorMessage ?? ''}`); + }, + async markReminderSent(input) { + calls.push(`sent:${input.id}`); + }, + async scheduleNextDailyReminder() { + calls.push('next'); + }, + async markReminderCancelled() { + calls.push('cancelled'); + }, + async markReminderFailed() { + calls.push('failed'); + }, + async listDueDigestSubscriptions() { + return []; + }, + async lockDigestSubscription() { + return null; + }, + async listDueBalanceAlerts() { + return []; + }, + async lockBalanceAlert() { + return null; + }, + }, + async sendWechatTextToUser(userId, text) { + sent.push({ userId, text }); + }, + logger: { warn() {} }, + runOnStart: false, + }); + + await worker.runOnce(); + worker.stop(); + + assert.deepEqual(sent, []); + assert.deepEqual(calls, [ + 'log:skipped:欠费停用:已跳过非基础推送', + 'sent:rem-news', + 'next', + ]); +}); + +test('schedule reminder worker still sends morning push for suspended users', async () => { + const sent = []; + const reminder = { + id: 'rem-morning', + userId: 'user-suspended', + itemId: 'item-morning', + remindAt: Date.now() - 1000, + channel: 'wechat', + attempts: 1, + }; + const worker = startScheduleReminderWorker({ + intervalMs: 60_000, + scheduleService: { + async listDueReminders() { + return [reminder]; + }, + async lockReminder() { + return reminder; + }, + async getUserStatusSnapshot() { + return 'suspended'; + }, + async getReminderPushSource() { + return pushSubscriptionMetadataSource('morning'); + }, + async buildReminderDelivery() { + return { text: '☀️ 早安问候\n\n今天也要加油', verifiedHtmlUrls: [] }; + }, + async createUserNotification() {}, + async logDelivery() {}, + async markReminderSent() {}, + async scheduleNextDailyReminder() {}, + async markReminderCancelled() {}, + async markReminderFailed() {}, + async listDueDigestSubscriptions() { + return []; + }, + async lockDigestSubscription() { + return null; + }, + async listDueBalanceAlerts() { + return []; + }, + async lockBalanceAlert() { + return null; + }, + }, + async sendWechatTextToUser(userId, text) { + sent.push({ userId, text }); + }, + logger: { warn() {} }, + runOnStart: false, + }); + + await worker.runOnce(); + worker.stop(); + + assert.equal(sent.length, 1); + assert.match(sent[0].text, /早安/); +}); + test('schedule reminder worker retries when wechat delivery is deferred', async () => { const calls = []; const reminder = { diff --git a/schedule-service.mjs b/schedule-service.mjs index a6752c4..69c1644 100644 --- a/schedule-service.mjs +++ b/schedule-service.mjs @@ -1148,6 +1148,21 @@ export function createScheduleService(pool, options = {}) { return rows[0] ?? null; }; + const getUserStatusSnapshot = async (userId) => { + if (!userId) return null; + const [rows] = await pool.query( + `SELECT status FROM h5_users WHERE id = ? LIMIT 1`, + [userId], + ); + return rows[0]?.status ?? null; + }; + + const getReminderPushSource = async (reminder) => { + if (!reminder?.userId || !reminder?.itemId) return null; + const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId }); + return String(item?.metadata?.source ?? '').trim() || null; + }; + const createUserNotification = async ({ userId, channel = 'web', @@ -1280,6 +1295,8 @@ export function createScheduleService(pool, options = {}) { logDelivery, buildTodoDigestText, getUserWalletSnapshot, + getUserStatusSnapshot, + getReminderPushSource, createUserNotification, listUserNotifications, markUserNotificationRead, diff --git a/scheduled-task-executor.mjs b/scheduled-task-executor.mjs index a36730a..72a9b47 100644 --- a/scheduled-task-executor.mjs +++ b/scheduled-task-executor.mjs @@ -16,6 +16,7 @@ import { import { localDateLabel } from './schedule-time.mjs'; import { executeCursorChannelCodeRun } from './wechat-cursor-agent-run.mjs'; import { resolveCursorScheduledTaskEligible } from './wechat-cursor-executor-policy.mjs'; +import { isSuspendedBillingUser } from './wechat/suspended-user-push-policy.mjs'; function messageText(message) { if (typeof message?.content === 'string') return message.content.trim(); @@ -483,6 +484,18 @@ export async function resendScheduledTaskWechatForReadyPage({ return false; } + const [userRows] = await pool.query( + `SELECT status FROM h5_users WHERE id = ? LIMIT 1`, + [normalizedUserId], + ); + if (isSuspendedBillingUser(userRows[0]?.status)) { + logger.info?.('[ScheduledTask] skip wechat page resend for suspended user', { + userId: normalizedUserId, + relativePath: normalizedPath, + }); + return false; + } + const [rows] = await pool.query( `SELECT id, title, notify_channel, last_result_json, last_run_at FROM h5_scheduled_tasks diff --git a/scheduled-task-executor.test.mjs b/scheduled-task-executor.test.mjs index e533639..50d0c14 100644 --- a/scheduled-task-executor.test.mjs +++ b/scheduled-task-executor.test.mjs @@ -15,6 +15,7 @@ import { isSimpleScheduledTaskReminder, looksLikeScheduledTaskNonDelivery, reconcileStuckStaticPageDeliveryContracts, + resendScheduledTaskWechatForReadyPage, resolveScheduledTaskDeliveryPollIntervalMs, resolveScheduledTaskDeliveryRetryDelaysMs, } from './scheduled-task-executor.mjs'; @@ -270,3 +271,28 @@ test('formatScheduledTaskDeliveryMessage wraps delivery body', () => { assert.match(text, /定时任务完成:每日新闻页/); assert.match(text, /页面已生成/); }); + +test('resendScheduledTaskWechatForReadyPage skips suspended users', async () => { + let sendCount = 0; + const sent = await resendScheduledTaskWechatForReadyPage({ + pool: { + async query(sql) { + if (sql.includes('SELECT status FROM h5_users')) { + return [[{ status: 'suspended' }]]; + } + throw new Error(`unexpected query: ${sql}`); + }, + }, + userId: 'user-suspended', + relativePath: 'public/news.html', + notificationDispatcher: { + async sendScheduleNotification() { + sendCount += 1; + return true; + }, + }, + logger: { info() {}, warn() {} }, + }); + assert.equal(sent, false); + assert.equal(sendCount, 0); +}); diff --git a/wechat/handlers/push-subscription.mjs b/wechat/handlers/push-subscription.mjs index 402090a..7ebddd0 100644 --- a/wechat/handlers/push-subscription.mjs +++ b/wechat/handlers/push-subscription.mjs @@ -34,6 +34,10 @@ import { import { resolveSubscribeMorningLlmIntent } from '../../wechat-subscribe-morning-llm.mjs'; import { formatNewsMorningPushDelivery } from '../push-content/news-morning-delivery.mjs'; import { parsePushSubscriptionSecretCode } from '../push-subscription-secret-codes.mjs'; +import { + canCommitPushSubscriptionForSuspendedUser, + isSuspendedBillingUser, +} from '../suspended-user-push-policy.mjs'; async function tryCommitSubscription({ userId, @@ -55,6 +59,12 @@ async function tryCommitSubscription({ if (!catalogItem.deliveryReady) { return formatPushSubscriptionNotReadyReply({ catalogItem }); } + if ( + isSuspendedBillingUser(boundUser) + && !canCommitPushSubscriptionForSuspendedUser(catalogItem.key) + ) { + return '账户已欠费停用,暂仅保留早安问候与每日天气推送。请先充值后再开通其它订阅。'; + } if (!scheduleService) { return '当前暂无法设置每日推送,请稍后再试。'; } @@ -269,6 +279,9 @@ export async function handlePushSubscriptionMessages({ } } if (secretCode?.kind === 'news_immediate') { + if (isSuspendedBillingUser(boundUser)) { + return '账户已欠费停用,暂不提供复杂页面推送。请先充值后再试。'; + } const delivery = await formatNewsMorningPushDelivery({ timezone, h5Root: String(h5Root ?? '').trim() || process.cwd(), diff --git a/wechat/suspended-user-push-policy.mjs b/wechat/suspended-user-push-policy.mjs new file mode 100644 index 0000000..d7cd854 --- /dev/null +++ b/wechat/suspended-user-push-policy.mjs @@ -0,0 +1,49 @@ +import { resolvePushSubscriptionFromMetadataSource } from './push-subscription-catalog.mjs'; + +/** 欠费停用(suspended)用户仍必须送达的基础推送。 */ +export const SUSPENDED_ESSENTIAL_PUSH_KEYS = Object.freeze(['morning', 'weather']); + +export function isSuspendedBillingUser(statusOrUser) { + const status = typeof statusOrUser === 'string' + ? statusOrUser + : String(statusOrUser?.status ?? '').trim(); + return status === 'suspended'; +} + +export function resolvePushSubscriptionKeyFromSource(source) { + return resolvePushSubscriptionFromMetadataSource(source)?.key ?? null; +} + +export function isPushSubscriptionSource(source) { + return resolvePushSubscriptionKeyFromSource(source) != null; +} + +export function isEssentialPushSubscriptionKey(key) { + return SUSPENDED_ESSENTIAL_PUSH_KEYS.includes(String(key ?? '').trim()); +} + +export function isEssentialPushSubscriptionSource(source) { + const key = resolvePushSubscriptionKeyFromSource(source); + return key ? isEssentialPushSubscriptionKey(key) : false; +} + +export function canCommitPushSubscriptionForSuspendedUser(catalogKey) { + return isEssentialPushSubscriptionKey(catalogKey); +} + +export function shouldSkipPushReminderForSuspendedUser({ userStatus, pushSource }) { + if (!isSuspendedBillingUser(userStatus)) return false; + if (!isPushSubscriptionSource(pushSource)) return false; + return !isEssentialPushSubscriptionSource(pushSource); +} + +export function sanitizeDeliveryForSuspendedUser(delivery, { userStatus, pushSource } = {}) { + if (!delivery || !isSuspendedBillingUser(userStatus)) return delivery; + if (shouldSkipPushReminderForSuspendedUser({ userStatus, pushSource })) { + return null; + } + return { + ...delivery, + verifiedHtmlUrls: [], + }; +} diff --git a/wechat/suspended-user-push-policy.test.mjs b/wechat/suspended-user-push-policy.test.mjs new file mode 100644 index 0000000..8c42b16 --- /dev/null +++ b/wechat/suspended-user-push-policy.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + canCommitPushSubscriptionForSuspendedUser, + isEssentialPushSubscriptionSource, + isSuspendedBillingUser, + sanitizeDeliveryForSuspendedUser, + shouldSkipPushReminderForSuspendedUser, +} from './suspended-user-push-policy.mjs'; +import { pushSubscriptionMetadataSource } from './push-subscription-catalog.mjs'; + +test('isSuspendedBillingUser matches suspended status only', () => { + assert.equal(isSuspendedBillingUser('suspended'), true); + assert.equal(isSuspendedBillingUser({ status: 'suspended' }), true); + assert.equal(isSuspendedBillingUser('active'), false); + assert.equal(isSuspendedBillingUser('disabled'), false); +}); + +test('essential push subscriptions stay enabled for suspended users', () => { + assert.equal( + isEssentialPushSubscriptionSource(pushSubscriptionMetadataSource('morning')), + true, + ); + assert.equal( + isEssentialPushSubscriptionSource(pushSubscriptionMetadataSource('weather')), + true, + ); + assert.equal( + isEssentialPushSubscriptionSource(pushSubscriptionMetadataSource('news')), + false, + ); +}); + +test('shouldSkipPushReminderForSuspendedUser skips complex subscriptions only', () => { + assert.equal( + shouldSkipPushReminderForSuspendedUser({ + userStatus: 'suspended', + pushSource: pushSubscriptionMetadataSource('morning'), + }), + false, + ); + assert.equal( + shouldSkipPushReminderForSuspendedUser({ + userStatus: 'suspended', + pushSource: pushSubscriptionMetadataSource('news'), + }), + true, + ); + assert.equal( + shouldSkipPushReminderForSuspendedUser({ + userStatus: 'active', + pushSource: pushSubscriptionMetadataSource('news'), + }), + false, + ); + assert.equal( + shouldSkipPushReminderForSuspendedUser({ + userStatus: 'suspended', + pushSource: '', + }), + false, + ); +}); + +test('sanitizeDeliveryForSuspendedUser drops complex page urls', () => { + const kept = sanitizeDeliveryForSuspendedUser( + { text: '早安', verifiedHtmlUrls: ['https://example.com/page.html'] }, + { + userStatus: 'suspended', + pushSource: pushSubscriptionMetadataSource('morning'), + }, + ); + assert.equal(kept.text, '早安'); + assert.deepEqual(kept.verifiedHtmlUrls, []); + + const skipped = sanitizeDeliveryForSuspendedUser( + { text: '新闻', verifiedHtmlUrls: ['https://example.com/page.html'] }, + { + userStatus: 'suspended', + pushSource: pushSubscriptionMetadataSource('news'), + }, + ); + assert.equal(skipped, null); +}); + +test('canCommitPushSubscriptionForSuspendedUser allows morning and weather only', () => { + assert.equal(canCommitPushSubscriptionForSuspendedUser('morning'), true); + assert.equal(canCommitPushSubscriptionForSuspendedUser('weather'), true); + assert.equal(canCommitPushSubscriptionForSuspendedUser('news'), false); +});