feat(wechat): keep morning/weather push for suspended billing users
Memind CI / Test, build, and release guards (push) Successful in 4m20s
Memind CI / Test, build, and release guards (push) Successful in 4m20s
欠费停用用户仍送达早安与天气,跳过复杂页面订阅与定时任务补发。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user