Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 107 additions & 34 deletions packages/plugins/store/src/expiry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { context, logger } from './context';
import {
claimExpiryStage,
getProduct,
listDueExpiryReminders,
listExpiringServices,
listReminderCandidates,
markExpiryFailure,
patchServiceInstance,
resetExpiryStage,
Expand All @@ -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;
/** 暂停后的保留期(毫秒):期间资源不可用但数据保留,超期终止。 */
Expand All @@ -38,42 +102,50 @@ 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<void> {
const nowMs = now.getTime();
await remindUpcoming(nowMs);
await processExpired(nowMs);
}

/** 1) 到期前提醒:窗口内且尚未提醒过的 ACTIVE 服务。 */
/**
* 1) 到期提醒链:按阶段逐档推进。
*
* 每档单独查询 + 单独限批(`listReminderCandidates`),一档积压不会把后面的档饿死。
* 同一轮里可以连推多档(例如服务停机三天,恢复后一次性补发),这是**有意的**:
* 漏发的提醒比迟发的提醒更糟。每档仍然是「先 CAS 认领再发通知」,所以重复执行
* 不会重复发;通知失败则把阶段退回上一档,下一轮重试。
*/
async function remindUpcoming(nowMs: number): Promise<void> {
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;
}
}

Expand All @@ -98,11 +170,12 @@ async function processExpired(nowMs: number): Promise<void> {
}

async function suspendExpired(service: ServiceInstanceRecord): Promise<void> {
// 认领: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;

// 已达重试上限:停止自动重试,仅告警(不落成功态)。
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/store/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
120 changes: 120 additions & 0 deletions packages/plugins/store/src/renewal-expiry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createFakeExtensions>,
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');
});
});
39 changes: 24 additions & 15 deletions packages/plugins/store/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,43 +751,52 @@ export async function claimExpiryStage(
* 回退到期阶段为「未进入」:仅当当前阶段匹配 `from` 时置 NULL,返回是否命中。
* 用于「认领阶段 → 副作用失败」时把阶段释放回可重试状态(如提醒写入失败)。
*/
export async function resetExpiryStage(id: string, from: string): Promise<boolean> {
export async function resetExpiryStage(
id: string,
from: string,
to: string | null = null,
): Promise<boolean> {
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<ServiceInstanceRecord[]> {
const rows = await extensions().listAll<StoreServiceInstanceData>(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<ServiceInstanceRecord[]> {
const rows = await extensions().listAll<StoreServiceInstanceData>(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 },
});
Expand Down
Loading