diff --git a/apps/api/tests/unit/notification-type-coverage.test.ts b/apps/api/tests/unit/notification-type-coverage.test.ts index 37c3cdf..059beec 100644 --- a/apps/api/tests/unit/notification-type-coverage.test.ts +++ b/apps/api/tests/unit/notification-type-coverage.test.ts @@ -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', ]; diff --git a/packages/plugins/store/src/data.ts b/packages/plugins/store/src/data.ts index b4f3971..e4b1609 100644 --- a/packages/plugins/store/src/data.ts +++ b/packages/plugins/store/src/data.ts @@ -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` 上。 @@ -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 { diff --git a/packages/plugins/store/src/index.ts b/packages/plugins/store/src/index.ts index 0e166a2..1df30a6 100644 --- a/packages/plugins/store/src/index.ts +++ b/packages/plugins/store/src/index.ts @@ -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, @@ -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(); }); diff --git a/packages/plugins/store/src/jobs-contract.test.ts b/packages/plugins/store/src/jobs-contract.test.ts index 993e3e8..b8a2b9f 100644 --- a/packages/plugins/store/src/jobs-contract.test.ts +++ b/packages/plugins/store/src/jobs-contract.test.ts @@ -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() { diff --git a/packages/plugins/store/src/notifications.ts b/packages/plugins/store/src/notifications.ts index dac963a..caf27bb 100644 --- a/packages/plugins/store/src/notifications.ts +++ b/packages/plugins/store/src/notifications.ts @@ -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 业务通知。失败只告警,不抛出。 */ diff --git a/packages/plugins/store/src/repository.ts b/packages/plugins/store/src/repository.ts index 6ccb405..2dd79ea 100644 --- a/packages/plugins/store/src/repository.ts +++ b/packages/plugins/store/src/repository.ts @@ -126,8 +126,15 @@ export function toCartItem(instance: ExtensionInstance): Cart } export function toOrder(instance: ExtensionInstance): 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, @@ -511,11 +518,59 @@ export async function createOrderInstance(data: OrderCreateInput): Promise { + const rows = await extensions().listAll(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 { + 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 { + const { updated } = await extensions().updateWhere( + orderModel, + { name: { eq: id } }, + { unpaidRemindedAt: { set: null } }, + ); + return updated > 0; +} + /** 原子状态迁移:仅当当前状态匹配时变更。 */ export async function changeOrderState( id: string, diff --git a/packages/plugins/store/src/unpaid-reminder.test.ts b/packages/plugins/store/src/unpaid-reminder.test.ts new file mode 100644 index 0000000..52f96ae --- /dev/null +++ b/packages/plugins/store/src/unpaid-reminder.test.ts @@ -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()), + listOrdersToRemind: mocks.listOrdersToRemind, + claimUnpaidReminder: mocks.claimUnpaidReminder, + releaseUnpaidReminder: mocks.releaseUnpaidReminder, + getOrder: mocks.getOrder, +})); + +vi.mock('./context', async (original) => ({ + ...(await original()), + 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 = {}) => ({ + 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; + 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); + }); +}); diff --git a/packages/plugins/store/src/unpaid-reminder.ts b/packages/plugins/store/src/unpaid-reminder.ts new file mode 100644 index 0000000..8c81784 --- /dev/null +++ b/packages/plugins/store/src/unpaid-reminder.ts @@ -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; +} + +function formatAmount(order: OrderRecord): string { + return `${(order.total / 100).toFixed(2)} ${order.currency}`; +} + +async function notify(input: NotifyInput): Promise { + 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 { + 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 };