feat(wechat): keep morning/weather push for suspended billing users
Memind CI / Test, build, and release guards (push) Successful in 4m20s

欠费停用用户仍送达早安与天气,跳过复杂页面订阅与定时任务补发。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-22 16:30:40 +08:00
parent bbee02b565
commit c36558e0f3
8 changed files with 391 additions and 1 deletions
+42 -1
View File
@@ -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,
+140
View File
@@ -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 = {
+17
View File
@@ -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,
+13
View File
@@ -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
+26
View File
@@ -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);
});
+13
View File
@@ -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(),
+49
View File
@@ -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: [],
};
}
@@ -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);
});