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
1 change: 1 addition & 0 deletions apps/api/tests/unit/notification-type-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const PLUGIN_EMITTERS = [
'packages/plugins/store/src/expiry.ts',
'packages/plugins/store/src/refund.ts',
'packages/plugins/store/src/renewal.ts',
'packages/plugins/store/src/unpaid-reminder.ts',
'packages/plugins/store-wallet/src/notifications.ts',
'packages/plugins/market/src/index.ts',
];
Expand Down
19 changes: 19 additions & 0 deletions packages/plugins/store/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,25 @@ export const orderModel = defineModel({
* 数值回收时间;写入时由 repository 从 `deletedAt` 统一派生。
*/
deletedAtMs: z.number().int().nullable().default(null),
/**
* 未付款催付的**计划发送时间**(epoch ms):下单时写入「现在 + 24h」。
*
* 用「到期时间戳」而不是「每天扫一遍创建时间」,是为了让催付也具备补发能力:
* 任务停了两天,恢复后第一条满足 `<= now` 的记录照样会发。索引列必须是数值型,
* 范围比较(`lte`)落在这一列上。
*/
unpaidRemindAtMs: z.number().int().nullable().default(null),
/**
* 已催付时间(ISO 字符串)。非空即「已催过」,是催付的幂等标记 ——
* 字符串列可以用 `eq: null` 表达「从未催付」,与 `deletedAt` 同一做法。
*/
unpaidRemindedAt: z.string().nullable().default(null),
}),
indexes: [
{ fields: ['userId'], types: { userId: 'string' } },
{ fields: ['state'], types: { state: 'string' } },
{ fields: ['unpaidRemindAtMs'], types: { unpaidRemindAtMs: 'number' } },
{ fields: ['unpaidRemindedAt'], types: { unpaidRemindedAt: 'string' } },
// 必需索引:扩展引擎只能按**已声明的索引列**过滤(其余字段会抛
// ExtensionUnknownField)。`deletedAt: { eq: null }` 用字符串列(IS NULL
// 可行),但清理任务的范围比较必须落在数值列 `deletedAtMs` 上。
Expand Down Expand Up @@ -275,6 +290,10 @@ export interface StoreOrderData {
deletedAt?: string | null;
/** `deletedAt` 的 epoch 毫秒镜像,范围查询用。 */
deletedAtMs?: number | null;
/** 未付款催付的计划发送时间(epoch ms);存量行没有这个键,读时按 null 处理。 */
unpaidRemindAtMs?: number | null;
/** 已催付时间(ISO);非空即「已催过」,是可重复执行的幂等标记。 */
unpaidRemindedAt?: string | null;
}

export interface StoreServiceInstanceData {
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { STORE_NOTIFICATION_TYPES } from './notifications';
import { changeService, quoteServiceChange, transferService } from './changes';
import { refundOrder, createRefundRequest, listRefundRequests, reviewRefundRequest } from './refund';
import { runExpirySweep, EXPIRY_SWEEP_INTERVAL_MS } from './expiry';
import { runUnpaidOrderReminder, UNPAID_REMIND_INTERVAL_MS } from './unpaid-reminder';
import {
getUpstreamProduct,
getUpstreamProductConfig,
Expand Down Expand Up @@ -142,6 +143,10 @@ export function registerStoreJobs(jobs: JobContext): void {
void jobs.schedule('fulfillment-poll', { everyMs: FULFILLMENT_POLL_INTERVAL_MS });

// 到期自动化:提醒 →(宽限期)→ 暂停 →(保留期)→ 终止。集群下单次执行。
jobs.handle('unpaid-order-reminder', async () => {
await runUnpaidOrderReminder();
});
void jobs.schedule('unpaid-order-reminder', { everyMs: UNPAID_REMIND_INTERVAL_MS });
jobs.handle('expiry-sweep', async () => {
await runExpirySweep();
});
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/store/src/jobs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const DOCUMENTED: Array<[string, number]> = [
['fulfillment-poll', 5_000],
['expiry-sweep', 60 * 60_000],
['order-purge', 6 * 60 * 60_000],
// 每小时一次:按「计划催付时间」扫描,比每日一次更容易补发(#12.5)。
['unpaid-order-reminder', 60 * 60_000],
];

function fakeJobContext() {
Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/store/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export const STORE_NOTIFICATION_TYPES: readonly NotificationTypeDefinition[] = [
{ type: 'service.changed', category: 'store', label: '服务配置已变更', order: 115 },
{ type: 'service.transferred', category: 'store', label: '服务已转移', order: 117 },
{ type: 'order.cancelled', category: 'store', label: '订单已取消', order: 35 },
// 未付款订单催付(对标 #12.5):与「订单待支付」(下单即发)分开,
// 用户可以只关掉催付而保留下单回执。
{ type: 'order.unpaid', category: 'store', label: '订单待支付提醒', order: 36 },
];

/** 写入一条 store 业务通知。失败只告警,不抛出。 */
Expand Down
59 changes: 57 additions & 2 deletions packages/plugins/store/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,15 @@ export function toCartItem(instance: ExtensionInstance<StoreCartItemData>): Cart
}

export function toOrder(instance: ExtensionInstance<StoreOrderData>): OrderRecord {
// `deletedAtMs` 是实现细节(索引用的数值镜像),不外暴露给接口调用方。
const { state, deletedAt, deletedAtMs: _deletedAtMs, ...rest } = instance.spec;
// `deletedAtMs` / 催付两列都是实现细节(索引镜像与内部标记),不外暴露给接口调用方。
const {
state,
deletedAt,
deletedAtMs: _deletedAtMs,
unpaidRemindAtMs: _unpaidRemindAtMs,
unpaidRemindedAt: _unpaidRemindedAt,
...rest
} = instance.spec;
return {
id: instance.name,
...rest,
Expand Down Expand Up @@ -511,11 +518,59 @@ export async function createOrderInstance(data: OrderCreateInput): Promise<Order
state: 'PENDING',
deletedAt: null,
deletedAtMs: null,
// 下单即排定催付时间:之后无论任务何时跑,都是由「到期时间戳」决定要不要发。
unpaidRemindAtMs: Date.now() + UNPAID_REMIND_AFTER_MS,
unpaidRemindedAt: null,
...data,
} as StoreOrderData);
return toOrder(created);
}

/** 未付款催付:下单后多久提醒(毫秒)。 */
export const UNPAID_REMIND_AFTER_MS = 24 * 60 * 60 * 1000;

/**
* 列出待催付订单:仍是 `PENDING`、从未催付、且已过计划催付时间。
*
* 三个条件都落在索引列上(`state` / `unpaidRemindedAt` / `unpaidRemindAtMs`),
* 所以不会退化成全表扫描;`unpaidRemindedAt: { eq: null }` 编译为 `IS NULL`,
* 已催过的行不会反复占批次。
*/
export async function listOrdersToRemind(
cutoffMs: number,
take: number,
): Promise<OrderRecord[]> {
const rows = await extensions().listAll<StoreOrderData>(orderModel, {
where: {
state: { eq: 'PENDING' },
unpaidRemindedAt: { eq: null },
unpaidRemindAtMs: { lte: cutoffMs },
},
orderBy: { field: 'unpaidRemindAtMs', desc: false },
});
return rows.slice(0, take).map(toOrder);
}

/** 认领催付:只有「尚未催付」的行会被改,返回是否抢到(重复执行不会重复发)。 */
export async function claimUnpaidReminder(id: string, at: Date): Promise<boolean> {
const { updated } = await extensions().updateWhere(
orderModel,
{ name: { eq: id }, unpaidRemindedAt: { eq: null } },
{ unpaidRemindedAt: { set: at.toISOString() } },
);
return updated > 0;
}

/** 通知发送失败时释放认领,让下一轮可以重试(否则用户永久收不到)。 */
export async function releaseUnpaidReminder(id: string): Promise<boolean> {
const { updated } = await extensions().updateWhere(
orderModel,
{ name: { eq: id } },
{ unpaidRemindedAt: { set: null } },
);
return updated > 0;
}

/** 原子状态迁移:仅当当前状态匹配时变更。 */
export async function changeOrderState(
id: string,
Expand Down
104 changes: 104 additions & 0 deletions packages/plugins/store/src/unpaid-reminder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
listOrdersToRemind: vi.fn(),
claimUnpaidReminder: vi.fn(),
releaseUnpaidReminder: vi.fn(),
getOrder: vi.fn(),
notifyCreate: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}));

vi.mock('./repository', async (original) => ({
...(await original<typeof import('./repository')>()),
listOrdersToRemind: mocks.listOrdersToRemind,
claimUnpaidReminder: mocks.claimUnpaidReminder,
releaseUnpaidReminder: mocks.releaseUnpaidReminder,
getOrder: mocks.getOrder,
}));

vi.mock('./context', async (original) => ({
...(await original<typeof import('./context')>()),
context: () => ({ notifications: { create: mocks.notifyCreate } }),
logger: () => ({ info: mocks.info, warn: mocks.warn, error: vi.fn() }),
}));

import { runUnpaidOrderReminder, UNPAID_REMIND_AFTER_MS } from './unpaid-reminder';

const order = (overrides: Record<string, unknown> = {}) => ({
id: 'order-1',
userId: 'buyer',
items: { items: [] },
total: 9900,
currency: 'CNY',
status: 'PENDING',
channelCode: null,
settlementCurrency: 'CNY',
settlementTotal: 9900,
fxRate: null,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});

beforeEach(() => {
vi.clearAllMocks();
mocks.claimUnpaidReminder.mockResolvedValue(true);
mocks.getOrder.mockImplementation(async (id: string) => order({ id }));
mocks.notifyCreate.mockResolvedValue(undefined);
mocks.releaseUnpaidReminder.mockResolvedValue(true);
});

describe('未付款订单催付', () => {
it('到点且仍未支付:发一条提醒,并把订单标记为已催付', async () => {
mocks.listOrdersToRemind.mockResolvedValue([order()]);
const now = new Date('2026-01-15T00:00:00Z');

await runUnpaidOrderReminder(now);

// 催付是「按计划时间戳」驱动:截止时间就是 now,任务晚跑几天照样补发。
expect(mocks.listOrdersToRemind).toHaveBeenCalledWith(now.getTime(), expect.any(Number));
expect(mocks.claimUnpaidReminder).toHaveBeenCalledWith('order-1', now);
expect(mocks.notifyCreate).toHaveBeenCalledTimes(1);
const payload = mocks.notifyCreate.mock.calls[0]![0] as Record<string, unknown>;
expect(payload.type).toBe('order.unpaid');
expect(payload.link).toBe('/account/orders/order-1');
// 金额按分转元:9900 分 → 99.00 CNY。
expect(payload.body).toContain('99.00 CNY');
});

it('认领失败(别的副本已处理)时不重复发', async () => {
mocks.listOrdersToRemind.mockResolvedValue([order()]);
mocks.claimUnpaidReminder.mockResolvedValue(false);

await runUnpaidOrderReminder(new Date());

expect(mocks.notifyCreate).not.toHaveBeenCalled();
});

it('认领后才发现订单已支付:不发', async () => {
mocks.listOrdersToRemind.mockResolvedValue([order()]);
mocks.getOrder.mockResolvedValue(order({ status: 'PAID' }));

await runUnpaidOrderReminder(new Date());

// 用户刚付完款就收到「请尽快支付」是最招投诉的 bug。
expect(mocks.notifyCreate).not.toHaveBeenCalled();
});

it('通知失败把标记退回未催付,下一轮重试', async () => {
mocks.listOrdersToRemind.mockResolvedValue([order()]);
mocks.notifyCreate.mockRejectedValue(new Error('notifications down'));

await runUnpaidOrderReminder(new Date());

expect(mocks.releaseUnpaidReminder).toHaveBeenCalledWith('order-1');
expect(mocks.warn).toHaveBeenCalled();
});

it('催付窗口是下单后 24 小时', () => {
expect(UNPAID_REMIND_AFTER_MS).toBe(24 * 60 * 60 * 1000);
});
});
85 changes: 85 additions & 0 deletions packages/plugins/store/src/unpaid-reminder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { context, logger } from './context';
import {
claimUnpaidReminder,
getOrder,
listOrdersToRemind,
releaseUnpaidReminder,
UNPAID_REMIND_AFTER_MS,
type OrderRecord,
} from './repository';

/**
* 未付款订单催付(对标 #12.5)。
*
* ZJMF 是「每日扫一遍,创建后第 N 天发一条」。这里换成**按到期时间戳**驱动:
* 下单时就把 `unpaidRemindAtMs = now + 24h` 写进订单,任务每小时扫一次
* `<= now` 的行。两种写法对用户看到的差别不大,但后者有两点好处:
*
* 1. **能补发**:任务停两天,恢复后照样把该发的发出去。对方的「每日等值窗口」
* 一旦那天没跑,这条提醒就永久丢了。
* 2. **可重复执行**:认领是一次条件更新(`unpaidRemindedAt IS NULL → 时间戳`),
* 同一笔订单重复扫到也只会发一次;通知失败则把标记退回 NULL 等下一轮。
*
* 只提醒「仍未支付」的订单:状态在扫描与认领之间被支付的话,认领条件里的
* `state` 不参与(认领只看标记),但通知前会再确认一次状态 —— 用户刚付完款
* 就收到「请尽快支付」是最容易招致投诉的那种 bug。
*/

/** 每小时扫一次:比「每日一次」更容易补发,代价只是一次索引查询。 */
export const UNPAID_REMIND_INTERVAL_MS = 60 * 60 * 1000;
/** 单轮处理上限,避免积压时一次拉太多。 */
export const UNPAID_REMIND_BATCH = 50;

interface NotifyInput {
userId: string;
type: string;
title: string;
body: string;
link: string;
data?: Record<string, unknown>;
}

function formatAmount(order: OrderRecord): string {
return `${(order.total / 100).toFixed(2)} ${order.currency}`;
}

async function notify(input: NotifyInput): Promise<boolean> {
try {
await context().notifications.create(input);
return true;
} catch (error) {
logger().warn(`store: 未付款催付通知发送失败(${input.data?.orderId ?? '?'}):${String(error)}`);
return false;
}
}

/** 催付入口:由内核周期任务调用。`now` 可注入,便于测试。 */
export async function runUnpaidOrderReminder(now: Date = new Date()): Promise<void> {
const due = await listOrdersToRemind(now.getTime(), UNPAID_REMIND_BATCH);
for (const order of due) {
// 认领:只有「尚未催付」的行会被改。抢不到说明别的副本/上一轮已经处理。
const claimed = await claimUnpaidReminder(order.id, now);
if (!claimed) continue;

// 认领与发送之间可能刚完成支付:用户刚付完就收到「请尽快支付」是最招投诉的 bug。
const fresh = await getOrder(order.id);
if (!fresh || fresh.status !== 'PENDING') continue;

const delivered = await notify({
userId: order.userId,
type: 'order.unpaid',
title: '订单待支付',
body: `订单 ${order.id}(${formatAmount(order)})尚未支付,请尽快完成支付。`,
link: `/account/orders/${order.id}`,
data: { orderId: order.id, total: order.total, currency: order.currency },
});
if (!delivered) {
// 退回未催付,下一轮重试;否则这笔订单永远不会再被提醒。
await releaseUnpaidReminder(order.id);
continue;
}
logger().info(`store: 已催付未付款订单 ${order.id}`);
}
}

export { UNPAID_REMIND_AFTER_MS };
Loading