diff --git a/packages/plugins/store/src/expiry.ts b/packages/plugins/store/src/expiry.ts index 22d483dc..9cd1acea 100644 --- a/packages/plugins/store/src/expiry.ts +++ b/packages/plugins/store/src/expiry.ts @@ -3,8 +3,8 @@ import { context, logger } from './context'; import { claimExpiryStage, getProduct, - listDueExpiryReminders, listExpiringServices, + listReminderCandidates, markExpiryFailure, patchServiceInstance, resetExpiryStage, @@ -25,8 +25,72 @@ import { providerForProduct } from './fulfillment'; * 续费,阶段被重置为 null(见 renewal.ts),新的到期时间自然会重新走一遍。 */ -/** 到期前提醒窗口(天)。 */ -export const EXPIRY_REMIND_DAYS = 7; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * 到期提醒链(对标 #12.5)。 + * + * 每一档都是一次 `expiryStage` 的 CAS 迁移:`null → reminded → reminded-final → + * overdue-1 → overdue-2`(然后进 `suspending`)。所以「提醒几次」是这张表决定的, + * 而「同一档只发一次、失败可重试」是 `claimExpiryStage` 保证的 —— 两者解耦。 + * + * 对标差异:ZJMF 是「到期前 2 次 + 到期后 3 次」,但它的宽限期比我们长(按天可配)。 + * 我方宽限期固定 3 天,硬塞 3 次逾期提醒会让最后一档和「已暂停」通知在同一轮发出, + * 用户同时收到两条相反的信息。所以逾期只排 2 档,第 3 次由「已暂停」通知承担。 + * 要改回 3 档,加一行即可。 + */ +export interface ExpiryReminderStep { + /** 认领成功后写入 `expiryStage` 的阶段名,同时是下一档的前置阶段。 */ + stage: string; + /** 相对到期的偏移(毫秒):负数=到期前,正数=到期后。 */ + offsetMs: number; + type: 'service.expiring' | 'service.overdue'; + title: string; + body: (service: ServiceInstanceRecord) => string; +} + +const FIRST_REMINDER_OFFSET_MS = -7 * DAY_MS; + +export const EXPIRY_REMINDER_STEPS: readonly ExpiryReminderStep[] = [ + { + stage: 'reminded', + offsetMs: FIRST_REMINDER_OFFSET_MS, + type: 'service.expiring', + title: '云服务即将到期', + body: (service) => + `「${service.productName}」将于 ${formatDate(service.expiresAt)} 到期,请及时续费。`, + }, + { + stage: 'reminded-final', + offsetMs: -1 * DAY_MS, + type: 'service.expiring', + title: '云服务明日到期', + body: (service) => + `「${service.productName}」将于 ${formatDate(service.expiresAt)} 到期。到期后有 3 天宽限期,逾期未续费将暂停服务。`, + }, + { + stage: 'overdue-1', + offsetMs: 1 * DAY_MS, + type: 'service.overdue', + title: '云服务已到期', + body: (service) => + `「${service.productName}」已于 ${formatDate(service.expiresAt)} 到期,仍在宽限期内,请尽快续费。`, + }, + { + stage: 'overdue-2', + offsetMs: 2 * DAY_MS, + type: 'service.overdue', + title: '云服务即将暂停', + body: (service) => + `「${service.productName}」宽限期仅剩 1 天,逾期未续费将暂停服务(暂停后保留数据 7 天,续费可恢复)。`, + }, +]; + +/** 提醒链的所有阶段名(`suspending` 认领时需要认出「仍在提醒阶段」)。 */ +export const REMINDER_STAGES: readonly string[] = EXPIRY_REMINDER_STEPS.map((step) => step.stage); + +/** 到期前提醒窗口(天),由提醒链的第一档推导,供查询窗口复用。 */ +export const EXPIRY_REMIND_DAYS = -FIRST_REMINDER_OFFSET_MS / DAY_MS; /** 到期后的宽限期(毫秒):期间仍可用,仅提醒。 */ export const GRACE_PERIOD_MS = 3 * 24 * 60 * 60 * 1000; /** 暂停后的保留期(毫秒):期间资源不可用但数据保留,超期终止。 */ @@ -38,8 +102,6 @@ export const EXPIRY_SWEEP_INTERVAL_MS = 60 * 60 * 1000; /** 上游到期动作最大自动重试次数(超过后停止自动重试并告警)。 */ export const EXPIRY_MAX_ATTEMPTS = 5; -const DAY_MS = 24 * 60 * 60 * 1000; - /** 到期扫描入口:由内核周期任务调用(集群下单次执行)。 */ export async function runExpirySweep(now: Date = new Date()): Promise { const nowMs = now.getTime(); @@ -47,33 +109,43 @@ export async function runExpirySweep(now: Date = new Date()): Promise { await processExpired(nowMs); } -/** 1) 到期前提醒:窗口内且尚未提醒过的 ACTIVE 服务。 */ +/** + * 1) 到期提醒链:按阶段逐档推进。 + * + * 每档单独查询 + 单独限批(`listReminderCandidates`),一档积压不会把后面的档饿死。 + * 同一轮里可以连推多档(例如服务停机三天,恢复后一次性补发),这是**有意的**: + * 漏发的提醒比迟发的提醒更糟。每档仍然是「先 CAS 认领再发通知」,所以重复执行 + * 不会重复发;通知失败则把阶段退回上一档,下一轮重试。 + */ async function remindUpcoming(nowMs: number): Promise { - const windowEnd = nowMs + EXPIRY_REMIND_DAYS * DAY_MS; - const due = await listDueExpiryReminders(nowMs, windowEnd, EXPIRY_SWEEP_BATCH); - for (const service of due) { - if (service.expiryStage) continue; // 已提醒/已进入后续阶段 - // 先原子认领阶段(防并发重复发送),再写通知;通知失败则**回退阶段**, - // 下一轮 sweep 会重试,用户不会永久漏掉提醒(此前先置 reminded 再通知, - // 通知失败被吞后阶段不回退,导致不可重投)。 - const claimed = await claimExpiryStage(service.id, null, 'reminded'); - if (!claimed) continue; - const delivered = await notify(service, { - type: 'service.expiring', - title: '云服务即将到期', - body: `「${service.productName}」将于 ${formatDate(service.expiresAt)} 到期,请及时续费。`, - link: `/account/services/${service.id}`, - data: { - serviceId: service.id, - expiresAt: service.expiresAt?.toISOString() ?? null, - }, - }); - if (!delivered) { - await resetExpiryStage(service.id, 'reminded'); - logger().warn(`store: expiry reminder not delivered for ${service.id}; stage released for retry`); - continue; + let previous: string | null = null; + for (const step of EXPIRY_REMINDER_STEPS) { + const due = await listReminderCandidates(previous, nowMs - step.offsetMs, EXPIRY_SWEEP_BATCH); + for (const service of due) { + const claimed = await claimExpiryStage(service.id, previous, step.stage); + if (!claimed) continue; + const delivered = await notify(service, { + type: step.type, + title: step.title, + body: step.body(service), + link: `/account/services/${service.id}`, + data: { + serviceId: service.id, + expiresAt: service.expiresAt?.toISOString() ?? null, + reminderStage: step.stage, + }, + }); + if (!delivered) { + // 退回上一档(不是无条件置空):否则前面的提醒会被重发。 + await resetExpiryStage(service.id, step.stage, previous); + logger().warn( + `store: expiry reminder (${step.stage}) not delivered for ${service.id}; stage released for retry`, + ); + continue; + } + logger().info(`store: expiry reminder (${step.stage}) sent for service ${service.id}`); } - logger().info(`store: expiry reminder sent for service ${service.id}`); + previous = step.stage; } } @@ -98,11 +170,12 @@ async function processExpired(nowMs: number): Promise { } async function suspendExpired(service: ServiceInstanceRecord): Promise { - // 认领:null | 'reminded' → 'suspending'(处理中)。已是 'suspending' 时走重试分支。 + // 认领:仍在提醒链上(或尚未进入任何阶段)→ 'suspending'(处理中)。 + // 已是 'suspending' 时走重试分支。 + const current = service.expiryStage ?? null; + const claimable = current === null || REMINDER_STAGES.includes(current); const claimed = - service.expiryStage === 'suspending' - ? true - : await claimExpiryStage(service.id, service.expiryStage === 'reminded' ? 'reminded' : null, 'suspending'); + service.expiryStage === 'suspending' ? true : claimable && (await claimExpiryStage(service.id, current, 'suspending')); if (!claimed) return; // 已达重试上限:停止自动重试,仅告警(不落成功态)。 diff --git a/packages/plugins/store/src/notifications.ts b/packages/plugins/store/src/notifications.ts index 3d48f9fc..dac963a6 100644 --- a/packages/plugins/store/src/notifications.ts +++ b/packages/plugins/store/src/notifications.ts @@ -57,6 +57,7 @@ export const STORE_NOTIFICATION_TYPES: readonly NotificationTypeDefinition[] = [ }, { type: 'service.renewed', category: 'store', label: '服务续费成功', order: 80 }, { type: 'service.expiring', category: 'store', label: '服务即将到期', order: 90 }, + { type: 'service.overdue', category: 'store', label: '服务已到期', order: 95 }, { type: 'service.suspended', category: 'store', label: '服务已停用', order: 100 }, { type: 'service.resumed', category: 'store', label: '服务已恢复', order: 105 }, { type: 'service.terminated', category: 'store', label: '服务已终止', order: 110 }, diff --git a/packages/plugins/store/src/renewal-expiry.test.ts b/packages/plugins/store/src/renewal-expiry.test.ts index 8014d6d5..dcfb3375 100644 --- a/packages/plugins/store/src/renewal-expiry.test.ts +++ b/packages/plugins/store/src/renewal-expiry.test.ts @@ -1389,3 +1389,123 @@ describe('expiry sweep', () => { expect(fake.all(SERVICE)[0]!.spec.expiryStage).toBe('suspending'); }); }); + +describe('expiry reminder chain (对标 #12.5)', () => { + beforeEach(() => { + resetContext(); + notifications.length = 0; + notificationsFail = false; + }); + + /** 到期时间相对 `now` 的偏移,其余字段按最小可用服务填。 */ + function seedService( + fake: ReturnType, + now: Date, + expiresInMs: number, + expiryStage: string | null, + name = 'svc-chain', + ): void { + const expiry = new Date(now.getTime() + expiresInMs); + fake.seed( + SERVICE, + { + userId: 'user-1', + orderId: null, + productId: 'p', + productName: '云服务器', + fulfillmentType: 'upstream_service', + providerId: null, + providerServiceId: null, + state: 'ACTIVE', + credentialsRef: null, + runtime: null, + amount: 100, + currency: 'CNY', + expiresAt: expiry.toISOString(), + expiresAtMs: expiry.getTime(), + expiryStage, + provisionedAt: null, + terminatedAt: null, + }, + name, + ); + } + + const DAY = 24 * 60 * 60 * 1000; + + it('到期前一天发第二档提醒(对方也是 2 次到期前提醒)', async () => { + const fake = createFakeExtensions(); + const now = new Date('2026-01-15T00:00:00Z'); + seedService(fake, now, 1 * DAY, 'reminded'); + bindContext(makeCtx(fake)); + + await runExpirySweep(now); + + expect(fake.all(SERVICE)[0]!.spec.expiryStage).toBe('reminded-final'); + expect(notifications).toHaveLength(1); + expect(notifications[0]!.type).toBe('service.expiring'); + }); + + it('逾期第 1/2 天各发一次逾期提醒,类型区别于到期前', async () => { + const fake = createFakeExtensions(); + const now = new Date('2026-01-15T00:00:00Z'); + seedService(fake, now, -1 * DAY, 'reminded-final'); + bindContext(makeCtx(fake)); + + await runExpirySweep(now); + expect(fake.all(SERVICE)[0]!.spec.expiryStage).toBe('overdue-1'); + expect(notifications.map((n) => n.type)).toEqual(['service.overdue']); + + notifications.length = 0; + const later = new Date(now.getTime() + 1 * DAY); + await runExpirySweep(later); + expect(fake.all(SERVICE)[0]!.spec.expiryStage).toBe('overdue-2'); + expect(notifications.map((n) => n.type)).toEqual(['service.overdue']); + }); + + it('停机补发:一次 sweep 就能把落下的几档全部补齐', async () => { + const fake = createFakeExtensions(); + const now = new Date('2026-01-15T00:00:00Z'); + // 已到期 2 天,但阶段还是 null(说明过去两天 cron 没跑)。 + seedService(fake, now, -2 * DAY, null); + bindContext(makeCtx(fake)); + + await runExpirySweep(now); + + // 漏发的提醒比迟发的提醒更糟:四档一次性补发,终态 overdue-2。 + expect(fake.all(SERVICE)[0]!.spec.expiryStage).toBe('overdue-2'); + expect(notifications).toHaveLength(4); + }); + + it('某一档通知失败:只退回上一档,不重发前面的提醒', async () => { + const fake = createFakeExtensions(); + const now = new Date('2026-01-15T00:00:00Z'); + seedService(fake, now, 1 * DAY, 'reminded'); + bindContext(makeCtx(fake)); + notificationsFail = true; + + await runExpirySweep(now); + + // 回到 'reminded' 而不是 null —— 置空会把第一档提醒重复发给用户。 + expect(fake.all(SERVICE)[0]!.spec.expiryStage).toBe('reminded'); + expect(notifications).toHaveLength(0); + + // 恢复后下一轮补上,且只补这一档。 + notificationsFail = false; + await runExpirySweep(now); + expect(fake.all(SERVICE)[0]!.spec.expiryStage).toBe('reminded-final'); + expect(notifications).toHaveLength(1); + }); + + it('逾期阶段仍能进入暂停(不能因为阶段名变了就卡住)', async () => { + const fake = createFakeExtensions(); + const now = new Date('2026-01-15T00:00:00Z'); + // 已过宽限期(3 天),阶段停在 overdue-2 —— 旧实现只认 'reminded'/null,会永久卡住。 + seedService(fake, now, -4 * DAY, 'overdue-2'); + bindContext(makeCtx(fake)); + + await runExpirySweep(now); + + expect(fake.all(SERVICE)[0]!.spec.state).toBe('SUSPENDED'); + }); +}); diff --git a/packages/plugins/store/src/repository.ts b/packages/plugins/store/src/repository.ts index 12708c49..6ccb4051 100644 --- a/packages/plugins/store/src/repository.ts +++ b/packages/plugins/store/src/repository.ts @@ -751,43 +751,52 @@ export async function claimExpiryStage( * 回退到期阶段为「未进入」:仅当当前阶段匹配 `from` 时置 NULL,返回是否命中。 * 用于「认领阶段 → 副作用失败」时把阶段释放回可重试状态(如提醒写入失败)。 */ -export async function resetExpiryStage(id: string, from: string): Promise { +export async function resetExpiryStage( + id: string, + from: string, + to: string | null = null, +): Promise { const { updated } = await extensions().updateWhere( serviceInstanceModel, { name: { eq: id }, expiryStage: { eq: from } }, // `set: null` 编译为 SQL NULL(与 claimExpiryStage 的 `eq: null` 对称)。 - { expiryStage: { set: null } }, + { expiryStage: { set: to } }, ); return updated > 0; } -/** 列出「已到期/临近到期」的活跃服务(按 expiresAtMs 升序,上限 take)。 */ -export async function listExpiringServices( - nowMs: number, +/** + * 列出到期提醒链的下一档候选:阶段仍是 `fromStage`、且到期时间已进入触发窗口。 + * + * 逐档查询(而不是把所有阶段一次捞出来再在内存里判断)有两个好处:每档都能单独 + * 限批,不会因为一档积压把后面几档饿死;锁语义也简单 —— `fromStage` 就是 + * `claimExpiryStage` 的 CAS 前置条件。 + */ +export async function listReminderCandidates( + fromStage: string | null, + dueBeforeMs: number, take: number, ): Promise { const rows = await extensions().listAll(serviceInstanceModel, { where: { - state: { in: ['ACTIVE', 'SUSPENDED'] }, - expiresAtMs: { lte: nowMs }, + state: { eq: 'ACTIVE' }, + expiresAtMs: { lte: dueBeforeMs }, + expiryStage: { eq: fromStage }, }, orderBy: { field: 'expiresAtMs', desc: false }, }); return rows.slice(0, take).map(toService); } -/** 列出「即将到期(尚未到期)」的服务,供到期提醒(窗口内、未提醒过)。 */ -export async function listDueExpiryReminders( - fromMs: number, - toMs: number, +/** 列出「已到期/临近到期」的活跃服务(按 expiresAtMs 升序,上限 take)。 */ +export async function listExpiringServices( + nowMs: number, take: number, ): Promise { const rows = await extensions().listAll(serviceInstanceModel, { where: { - state: { eq: 'ACTIVE' }, - expiresAtMs: { gte: fromMs, lte: toMs }, - // 只提醒尚未进入任何阶段(expiryStage IS NULL)的服务,避免已提醒记录占满批次。 - expiryStage: { eq: null }, + state: { in: ['ACTIVE', 'SUSPENDED'] }, + expiresAtMs: { lte: nowMs }, }, orderBy: { field: 'expiresAtMs', desc: false }, });