@tbox.cn/app-module-promotion 0.2.0 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +90 -11
  3. package/dist/chunk-QOTX4XDS.js +1745 -0
  4. package/dist/chunk-S7CVFCML.js +1330 -0
  5. package/dist/client/index.d.ts +21 -3
  6. package/dist/client/index.js +4 -2
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +16 -4
  9. package/dist/promotion-6L7HLRC2.css +2135 -0
  10. package/dist/server/index.d.ts +2481 -41
  11. package/dist/server/index.js +13 -3
  12. package/package.json +28 -15
  13. package/skills/promotion/SKILL.md +137 -0
  14. package/skills/promotion/references/claim-loop.md +18 -0
  15. package/src/client/cards/index.ts +10 -0
  16. package/src/client/cards/promotion-activity-category-list/index.tsx +34 -0
  17. package/src/client/cards/promotion-activity-detail/index.tsx +84 -0
  18. package/src/client/cards/promotion-activity-list/index.tsx +56 -0
  19. package/src/client/cards/promotion-best-deal/index.tsx +18 -0
  20. package/src/client/cards/promotion-entitlement-list/index.tsx +161 -0
  21. package/src/client/cards/promotion-offer-detail/index.tsx +116 -0
  22. package/src/client/cards/promotion-offer-list/index.tsx +283 -0
  23. package/src/client/cards/view.ts +72 -0
  24. package/src/client/components/CouponQrCode.tsx +48 -0
  25. package/src/client/components/CouponSheet.tsx +124 -0
  26. package/src/client/components/ImageWithFallback.tsx +39 -0
  27. package/src/client/index.ts +23 -4
  28. package/src/client/styles/promotion.css +2135 -0
  29. package/src/client/types/css.d.ts +2 -0
  30. package/src/client/utils/sanitize-activity-html.ts +48 -0
  31. package/src/client/utils/scroll-card-bottom.ts +40 -0
  32. package/src/server/actions.ts +217 -0
  33. package/src/server/cards/index.ts +19 -0
  34. package/src/server/cards/promotion-activity-category-list/meta.ts +13 -0
  35. package/src/server/cards/promotion-activity-detail/meta.ts +29 -0
  36. package/src/server/cards/promotion-activity-list/meta.ts +13 -0
  37. package/src/server/cards/promotion-best-deal/meta.ts +20 -0
  38. package/src/server/cards/promotion-entitlement-list/meta.ts +17 -0
  39. package/src/server/cards/promotion-entitlement-list/samples.ts +51 -0
  40. package/src/server/cards/promotion-offer-detail/meta.ts +25 -0
  41. package/src/server/cards/promotion-offer-list/meta.ts +20 -0
  42. package/src/server/cards/promotion-offer-list/samples.ts +154 -0
  43. package/src/server/handler.ts +378 -16
  44. package/src/server/index.ts +117 -22
  45. package/src/server/navigation.ts +23 -0
  46. package/src/server/ports.ts +56 -0
  47. package/src/server/service.ts +243 -24
  48. package/src/server/tools.ts +789 -0
  49. package/tbox.module.json +126 -0
  50. package/tests/cards-render.test.tsx +656 -0
  51. package/tests/ports-resolve.test.ts +96 -0
  52. package/tests/pretool-coupons.test.ts +63 -0
  53. package/tests/promotion.test.ts +976 -0
  54. package/tests/samples.test.ts +42 -0
  55. package/tests/view.test.ts +61 -0
  56. package/tsup.config.ts +9 -1
  57. package/dist/chunk-AD6OLHSM.js +0 -86
  58. package/dist/chunk-LQGF6337.js +0 -32
  59. package/src/client/cards/coupon/index.tsx +0 -12
  60. package/src/server/cards/coupon/meta.ts +0 -23
  61. package/tbox.component.json +0 -20
  62. package/tests/service.test.ts +0 -31
@@ -0,0 +1,976 @@
1
+ /**
2
+ * 015 S6:promotion 测试。
3
+ * - acquire 闭环(重复点击交由 Provider / member.status 会员门禁八态 / 三态卡 / 业务错误透传)
4
+ * - InMemory 四 Port 演示语义
5
+ * - M1' 承接:listEntitlements itemAction value(话术锚定券名)+ 工具描述分工断言
6
+ */
7
+ import { describe, expect, it, vi } from 'vitest';
8
+
9
+ // mock ports 服务解析(tool 内部 import;缺省回真实 InMemory 单例——actions 层 monkey-patch 用例不破)
10
+ vi.mock('../src/server/ports', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import('../src/server/ports')>();
12
+ const fallback = vi.fn(async () => actual.inMemoryPromotion);
13
+ return {
14
+ ...actual,
15
+ resolvePromotionOffersPort: fallback,
16
+ resolvePromotionEntitlementsPort: fallback,
17
+ resolvePromotionActivitiesPort: fallback,
18
+ resolvePromotionAcquisitionPort: fallback,
19
+ };
20
+ });
21
+
22
+ import {
23
+ promotionActivityListCardDataSchema,
24
+ promotionEntitlementListCardDataSchema,
25
+ } from '@tbox.cn/app-contracts-mall';
26
+ import { InMemoryPromotionService } from '../src/server/service';
27
+ import { classifyRejection, createPromotionActions } from '../src/server/actions';
28
+ import { inMemoryPromotion } from '../src/server/ports';
29
+ import {
30
+ resolvePromotionOffersPort,
31
+ resolvePromotionEntitlementsPort,
32
+ resolvePromotionActivitiesPort,
33
+ resolvePromotionAcquisitionPort,
34
+ } from '../src/server/ports';
35
+
36
+ /** 四槽 resolver 持久注入(跨多次解析保持;vi.clearAllMocks 复位) */
37
+ const mockAllSlotsPersist = (service: unknown) => {
38
+ vi.mocked(resolvePromotionOffersPort).mockResolvedValue(service as never);
39
+ vi.mocked(resolvePromotionEntitlementsPort).mockResolvedValue(service as never);
40
+ vi.mocked(resolvePromotionActivitiesPort).mockResolvedValue(service as never);
41
+ vi.mocked(resolvePromotionAcquisitionPort).mockResolvedValue(service as never);
42
+ };
43
+
44
+ /** 四槽 resolver 统一注入(017 槽位原子化:工具/动作按语义选槽,测试恒全注入) */
45
+ const mockAllSlots = (service: unknown) => {
46
+ vi.mocked(resolvePromotionOffersPort).mockResolvedValueOnce(service as never);
47
+ vi.mocked(resolvePromotionEntitlementsPort).mockResolvedValueOnce(service as never);
48
+ vi.mocked(resolvePromotionActivitiesPort).mockResolvedValueOnce(service as never);
49
+ vi.mocked(resolvePromotionAcquisitionPort).mockResolvedValueOnce(service as never);
50
+ };
51
+ import { createPromotionTools, offerDetail, offerSummary } from '../src/server/tools';
52
+ import { createPromotionIntentHandler, PROMOTION_INTENTS } from '../src/server/handler';
53
+ import { createServerContext, createToolExecContext, matchIntent } from '@tbox.cn/app-sdk/server';
54
+ import type { IntentRoute } from '@tbox.cn/app-sdk/server';
55
+
56
+ const q = { scope: { miniAppId: 'app', mallId: 'mall-1' }, subjectId: 'u_1' } as never;
57
+
58
+ /** 上下文统一化:显式缝——reqCtx 由调用方透传(handle 第四参 / 工具 execute 第二参) */
59
+ const testRequestContext = (userId = 'u_1') => ({
60
+ identity: { userId, source: 'dev' as const },
61
+ attributes: { mall: { miniAppId: 'app', mallId: 'mall-1' } },
62
+ });
63
+
64
+ function createPromotionTestContext(_userId = 'u_1') {
65
+ return createServerContext();
66
+ }
67
+
68
+ /** 外层意图路由测试表(镜像 collectIntentRoutes 装配语义:keywords/match 同源 + 导航前缀路由) */
69
+ const PROMOTION_TEST_ROUTES: IntentRoute[] = [
70
+ ...Object.entries(PROMOTION_INTENTS).map(
71
+ ([kind, intent]) => ({
72
+ id: `promotion:${intent.patterns[0]}`,
73
+ keywords: [...intent.patterns],
74
+ handlerId: 'promotion',
75
+ action: kind,
76
+ match: 'exact' as const,
77
+ }),
78
+ ),
79
+ { id: 'promotion:navigation-prefix', keywords: ['查看「'], handlerId: 'promotion', action: 'intent', match: 'prefix' as const },
80
+ ];
81
+
82
+ /** 意图可连性(顶层确定性档:exact 整句全等 + 导航模板句头 prefix 命中) */
83
+ function expectPromotionIntentRoutable(query: string) {
84
+ expect(matchIntent(PROMOTION_TEST_ROUTES, query)?.handlerId).toBe('promotion');
85
+ }
86
+
87
+ describe('InMemoryPromotionService', () => {
88
+ it('projects provider C-end coupon display facts into both card shapes', () => {
89
+ const offer = {
90
+ offerRef: 'o1', title: '券', offerType: 'COUPON' as const, tags: [],
91
+ validity: { end: '' }, facets: [], availability: { status: 'AVAILABLE' as const, reasonCodes: [], evaluatedAt: '', authoritative: true },
92
+ priceText: '免费', originalPriceText: '¥50', discountLabel: '5折', claimActionLabel: '领取',
93
+ purchaseUrl: 'alipays://platformapi/startapp?appId=demo&page=coupon', freeCouponClaimMode: 'REDIRECT' as const,
94
+ stockText: '剩余:42', usageSceneText: '小程序扫码买单', guaranteeText: '随时退·过期自动退',
95
+ };
96
+ expect(offerSummary(offer)).toMatchObject({ priceText: '免费', originalPriceText: '¥50', discountLabel: '5折', claimActionLabel: '领取', purchaseUrl: 'alipays://platformapi/startapp?appId=demo&page=coupon', freeCouponClaimMode: 'REDIRECT', stockText: '剩余:42', usageSceneText: '小程序扫码买单', guaranteeText: '随时退·过期自动退' });
97
+ expect(offerDetail(offer, true, 'coupon')).toMatchObject({ priceText: '免费', originalPriceText: '¥50', discountLabel: '5折', claimActionLabel: '领取', stockText: '剩余:42', usageSceneText: '小程序扫码买单', guaranteeText: '随时退·过期自动退' });
98
+ });
99
+
100
+ it('does not render a missing coupon threshold as 满0减', () => {
101
+ const summary = offerSummary({
102
+ offerRef: 'o-no-threshold', title: '无门槛券', offerType: 'COUPON', tags: [], validity: { end: '' },
103
+ facets: [{ kind: 'COUPON', discountType: 'AMOUNT', value: 5500 }],
104
+ availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: '', authoritative: true },
105
+ });
106
+ expect(summary.benefitText).toBe('¥55');
107
+ expect(summary).not.toHaveProperty('thresholdText');
108
+ });
109
+
110
+ it('保留优惠金额的小数精度,避免 LLM 摘要丢失金额', () => {
111
+ const summary = offerSummary({
112
+ offerRef: 'o-five-cents', title: '小额券', offerType: 'COUPON', tags: [], validity: { end: '' },
113
+ facets: [{ kind: 'COUPON', discountType: 'AMOUNT', value: 5, thresholdCents: 5 }],
114
+ availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: '', authoritative: true },
115
+ });
116
+ expect(summary).toMatchObject({ benefitText: '满 0.05 减 0.05 元', thresholdText: '满 0.05 元可用' });
117
+ });
118
+
119
+ it('prefers provider validity text over the fixed date range', () => {
120
+ const summary = offerSummary({
121
+ offerRef: 'o-relative-validity', title: '领取后有效', offerType: 'COUPON', tags: [],
122
+ validity: { start: '2026-09-03', end: '2027-01-07' }, validityText: '含领取之日起7天内有效',
123
+ facets: [], availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: '', authoritative: true },
124
+ });
125
+ expect(summary.validityText).toBe('含领取之日起7天内有效');
126
+ });
127
+
128
+ it('搜索/详情/推荐', async () => {
129
+ const service = new InMemoryPromotionService();
130
+ const page = await service.searchOffers(q, { keyword: '餐饮' });
131
+ expect(page.items.length).toBeGreaterThan(0);
132
+ expect(page.items.every((o) => o.title.includes('餐饮') || o.tags.some((t) => t.includes('餐饮')))).toBe(true);
133
+
134
+ const detail = await service.getOfferDetail(q, { offerRef: 'demo:coupon:1001' });
135
+ expect(detail?.title).toContain('满 100 减 20');
136
+ expect(detail?.facets[0]).toMatchObject({ kind: 'COUPON', discountType: 'AMOUNT', value: 2000 });
137
+
138
+ const keywordFallback = await service.getOfferDetail(q, { keyword: '团购' });
139
+ expect(keywordFallback?.offerType).toBe('GROUPON');
140
+
141
+ const recommended = await service.recommendOffers(q, {});
142
+ expect(recommended.length).toBeLessThanOrEqual(3);
143
+ });
144
+
145
+ it('最优测算:AMOUNT 与 PERCENTAGE 门槛过滤', async () => {
146
+ const service = new InMemoryPromotionService();
147
+ const under = await service.calculateBestDeal(q, { currentAmountCents: 5000 });
148
+ expect(under.estimatedSavingCents).toBeGreaterThan(0); // 餐饮 85 折满 50 可用
149
+ const over = await service.calculateBestDeal(q, { currentAmountCents: 20000 });
150
+ expect(over.estimatedSavingCents).toBe(3000); // 85 折省 30 元 > 满 100 减 20
151
+ });
152
+
153
+ it('厂商未提供测算能力时只返回文字,不出优惠测算卡', async () => {
154
+ const service = {
155
+ supportsBestDeal: false,
156
+ searchOffers: vi.fn(async () => ({ items: [], hasMore: false })),
157
+ };
158
+ mockAllSlots(service);
159
+ const tools = createPromotionTools({ getCtx: () => createServerContext() });
160
+ const result = await tools.calculateBestDeal.execute!(
161
+ { currentAmountCents: '32800' },
162
+ createToolExecContext(testRequestContext()) as never,
163
+ ) as { supported?: boolean; cards?: unknown[]; message?: string };
164
+ expect(result).toMatchObject({ supported: false, message: '当前商场暂不支持优惠测算,请在结算页确认最终优惠' });
165
+ expect(result.cards).toBeUndefined();
166
+ expect(service.searchOffers).not.toHaveBeenCalled();
167
+ vi.mocked(resolvePromotionOffersPort).mockReset(); vi.mocked(resolvePromotionEntitlementsPort).mockReset(); vi.mocked(resolvePromotionActivitiesPort).mockReset(); vi.mocked(resolvePromotionAcquisitionPort).mockReset();
168
+ });
169
+
170
+ it('厂商要求统一券列表时,推荐结果不进入旧 recommendation 展示分支', async () => {
171
+ const service = new InMemoryPromotionService();
172
+ Object.defineProperty(service, 'usesUnifiedCouponList', { value: true });
173
+ mockAllSlots(service);
174
+ const tools = createPromotionTools({ getCtx: () => createServerContext() });
175
+ const result = await tools.recommendOffers.execute!(
176
+ {},
177
+ createToolExecContext(testRequestContext()) as never,
178
+ ) as { presentation?: string; mode?: string; cards?: { cardType: string; data: { presentation?: string; mode?: string } }[] };
179
+ expect(result).toMatchObject({
180
+ presentation: 'coupon',
181
+ mode: 'claimable',
182
+ cards: [{ cardType: 'promotion-offer-list', data: { presentation: 'coupon', mode: 'claimable' } }],
183
+ });
184
+ vi.mocked(resolvePromotionOffersPort).mockReset(); vi.mocked(resolvePromotionEntitlementsPort).mockReset(); vi.mocked(resolvePromotionActivitiesPort).mockReset(); vi.mocked(resolvePromotionAcquisitionPort).mockReset();
185
+ });
186
+
187
+ it('acquire 幂等 + entitlement 落账', async () => {
188
+ const service = new InMemoryPromotionService();
189
+ const key = 'idem-1';
190
+ const first = await service.acquire(q, { offerRef: 'demo:coupon:1001', externalMemberRef: 'member-ref:abc', idempotencyKey: key });
191
+ expect(first.status).toBe('SUCCEEDED');
192
+ const second = await service.acquire(q, { offerRef: 'demo:coupon:1001', externalMemberRef: 'member-ref:abc', idempotencyKey: key });
193
+ expect(second.operationRef).toBe(first.operationRef); // 幂等返回首果
194
+
195
+ const ents = await service.listEntitlements(q, {});
196
+ expect(ents.items).toHaveLength(1);
197
+ expect(ents.items[0]!.codeMasked).toContain('**');
198
+
199
+ const bad = await service.acquire(q, { offerRef: 'missing', externalMemberRef: 'm', idempotencyKey: 'k2' });
200
+ expect(bad.status).toBe('REJECTED');
201
+ expect(bad.code).toBe('30017');
202
+ });
203
+
204
+ it('活动列表与详情(场次/指南)', async () => {
205
+ const service = new InMemoryPromotionService();
206
+ const list = await service.listActivities(q, {});
207
+ expect(list.items).toHaveLength(1);
208
+ const detail = await service.getActivityDetail(q, list.items[0]!.activityRef);
209
+ expect(detail?.sessions).toHaveLength(2);
210
+ expect(detail?.guide?.conditions).toContain('限会员本人参与');
211
+ });
212
+ });
213
+
214
+ describe('legacy marketing 能力面', () => {
215
+ it('完整注册 17 个营销域工具(碰出惊喜 2 工具已迁出至 module-alipay-activity)', () => {
216
+ const tools = createPromotionTools({ getCtx: () => createServerContext() });
217
+ expect(Object.keys(tools)).toEqual([
218
+ 'listActivityCategories',
219
+ 'listActivities',
220
+ 'getActivityDetail',
221
+ 'listActivitySessions',
222
+ 'getActivityGuide',
223
+ 'searchOffers',
224
+ 'getOfferDetail',
225
+ 'listEntitlements',
226
+ 'listPromotions',
227
+ 'getPromotionDetail',
228
+ 'getParticipationGuide',
229
+ 'listCoupons',
230
+ 'getPackageDetail',
231
+ 'recommendOffers',
232
+ 'calculateBestDeal',
233
+ ]);
234
+ });
235
+
236
+ it('领取 Action 绑定券列表与通用详情卡两处', () => {
237
+ const actions = createPromotionActions({} as never);
238
+ // 券走列表浮层(一卡多券,令牌必须可复用);活动/礼包等非券优惠仍从详情卡领
239
+ expect(actions.acquireFromList.cardType).toBe('promotion-offer-list');
240
+ expect(actions.acquireFromList.singleUse).toBe(false);
241
+ expect(actions.acquire.cardType).toBe('promotion-offer-detail');
242
+ expect((actions as Record<string, unknown>).acquireConfirm).toBeUndefined();
243
+ });
244
+ });
245
+
246
+ describe('classifyRejection(已知码语义兜底)', () => {
247
+ it('保留源码中的已知错误分类,未知码仍使用通用兜底', () => {
248
+ expect(classifyRejection('636').kind).toBe('OUT_OF_STOCK');
249
+ expect(classifyRejection('637').kind).toBe('OUT_OF_STOCK');
250
+ expect(classifyRejection('638').kind).toBe('ALREADY_ACQUIRED');
251
+ expect(classifyRejection('5113').kind).toBe('ALREADY_ACQUIRED');
252
+ expect(classifyRejection('30017').kind).toBe('NOT_ELIGIBLE');
253
+ expect(classifyRejection('100408').kind).toBe('UNKNOWN');
254
+ });
255
+ });
256
+
257
+ describe('promotion 确定性意图路由', () => {
258
+ it('活动意图在专属策略下分流到联单活动 handler,其他 provider 保持原活动卡', async () => {
259
+ const ctx = createServerContext();
260
+ const alipayActivityHandler = {
261
+ id: 'alipay-activity',
262
+ intents: [],
263
+ handle: vi.fn(async (_action: string, _payload: unknown, _ctx: unknown, reqCtx: { credentials?: { provider?: string } }) =>
264
+ reqCtx.credentials?.provider === 'target'
265
+ ? { cards: [{ cardType: 'promotion-bumpin-activity', data: {} }], text: '联单活动' }
266
+ : { cards: [], continueWith: { content: '活动中心' } },
267
+ ),
268
+ };
269
+ ctx.handlers.register(alipayActivityHandler as never);
270
+ const handler = createPromotionIntentHandler(ctx);
271
+
272
+ const linked = await handler.handle('intent', { content: '活动中心' }, ctx, {
273
+ ...testRequestContext(),
274
+ credentials: { token: 'target-token', provider: 'target' },
275
+ });
276
+ expect(linked.cards[0]?.cardType).toBe('promotion-bumpin-activity');
277
+ expect(alipayActivityHandler.handle).toHaveBeenCalledWith('activity-router', expect.anything(), ctx, expect.objectContaining({
278
+ credentials: expect.objectContaining({ provider: 'target' }),
279
+ }));
280
+
281
+ const otherProvider = await handler.handle('intent', { content: '活动中心' }, ctx, {
282
+ ...testRequestContext(),
283
+ credentials: { token: 'other-token', provider: 'other' },
284
+ });
285
+ expect(otherProvider.cards[0]?.cardType).toBe('promotion-activity-list');
286
+ });
287
+
288
+ it('活动工具在专属策略下先分流,不依赖 promotion service 解析', async () => {
289
+ const ctx = createServerContext();
290
+ const alipayActivityHandler = {
291
+ id: 'alipay-activity',
292
+ intents: [],
293
+ handle: vi.fn(async () => ({
294
+ cards: [{ cardType: 'promotion-bumpin-activity', data: {} }],
295
+ text: '联单活动',
296
+ })),
297
+ };
298
+ ctx.handlers.register(alipayActivityHandler as never);
299
+ const resolveCallsBefore = vi.mocked(resolvePromotionActivitiesPort).mock.calls.length;
300
+ const tools = createPromotionTools({ getCtx: () => ctx });
301
+ const result = await tools.listActivities.execute!({}, createToolExecContext({
302
+ ...testRequestContext(),
303
+ credentials: { token: 'target-token', provider: 'target' },
304
+ }) as never);
305
+
306
+ expect(result).toMatchObject({ cards: [{ cardType: 'promotion-bumpin-activity' }] });
307
+ expect(vi.mocked(resolvePromotionActivitiesPort).mock.calls.length).toBe(resolveCallsBefore);
308
+ expect(alipayActivityHandler.handle).toHaveBeenCalledWith('activity-router', { content: '活动中心' }, ctx, expect.anything());
309
+ });
310
+
311
+ it('“最近有什么营销活动”只返回活动卡,不经过会员积分候选卡', async () => {
312
+ const ctx = createServerContext();
313
+ const reqCtx = { identity: { userId: 'u_activity', source: 'dev' as const }, attributes: { mall: { miniAppId: 'app', mallId: 'mall-1' } } };
314
+ const handler = createPromotionIntentHandler(ctx);
315
+
316
+ expect(handler.intents?.flatMap((intent) => intent.patterns)).toEqual(
317
+ expect.arrayContaining([
318
+ ...PROMOTION_INTENTS['list-entitlements'].patterns,
319
+ ...PROMOTION_INTENTS['list-activities'].patterns,
320
+ ]),
321
+ );
322
+
323
+ const result = await handler.handle('intent', { content: '最近有什么营销活动?' }, ctx, reqCtx);
324
+
325
+ expect(result.cards).toHaveLength(1);
326
+ expect(result.cards[0]?.cardType).toBe('promotion-activity-list');
327
+ expect(result.cards.some((card) => card.cardType === 'member-point-usage-list')).toBe(false);
328
+ expect(promotionActivityListCardDataSchema.safeParse(result.cards[0]?.data).success).toBe(true);
329
+ expect(result.cards[0]?.data).toMatchObject({
330
+ items: [
331
+ expect.objectContaining({
332
+ activityRef: expect.any(String),
333
+ title: '周末亲子市集',
334
+ availabilityStatus: expect.any(String),
335
+ itemActions: [
336
+ expect.objectContaining({
337
+ kind: 'send',
338
+ label: '查看活动详情',
339
+ value: expect.stringContaining('周末亲子市集'),
340
+ }),
341
+ ],
342
+ }),
343
+ ],
344
+ });
345
+ const activity = (result.cards[0]?.data as {
346
+ items: { activityRef: string; itemActions: { value?: string }[] }[];
347
+ }).items[0];
348
+ expect(activity.itemActions[0]?.value).toBe('查看「周末亲子市集」的活动详情');
349
+ expect(activity.itemActions[0]?.value).not.toContain(activity.activityRef);
350
+ });
351
+
352
+ it('意图路由收窄:跨域泛词问句不再误入营销意图(便宜/折扣/团购/市集/展览)', () => {
353
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '这个手机好便宜哪里买')).toBeUndefined();
354
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '哪家店折扣力度大')).toBeUndefined();
355
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '公司团建去哪里玩')).toBeUndefined();
356
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '附近有什么展览馆')).toBeUndefined();
357
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '周末市集人好多好热闹')).toBeUndefined();
358
+ });
359
+
360
+ it('意图路由保持:exact 固定句与导航模板前缀仍可路由', () => {
361
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '活动中心')?.handlerId).toBe('promotion');
362
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '有什么优惠')?.handlerId).toBe('promotion');
363
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '现在有什么优惠?')?.handlerId).toBe('promotion');
364
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '查看「周末亲子市集」的活动详情')?.handlerId).toBe('promotion');
365
+ });
366
+
367
+ it('复合句回归锚:自然语言不再被 BM25 单标签截断(走 LLM 多工具编排)', () => {
368
+ // 「推荐门店+领券」曾 BM25 命中 search-offers 直出券卡、吞掉导购半句(2026-08-31 实证)
369
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '给我推荐一下哪些导购的门店,并且有哪些优惠券可以领')).toBeUndefined();
370
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '最近有什么营销活动')).toBeUndefined();
371
+ expect(matchIntent(PROMOTION_TEST_ROUTES, '查询我的优惠券')).toBeUndefined();
372
+ });
373
+
374
+ it.each(['查询我的优惠券', '查看我现在持有的优惠券'])(
375
+ '“%s”直达已领取券列表,不被可领券关键词截获',
376
+ async (content) => {
377
+ const service = new InMemoryPromotionService();
378
+ await service.acquire(q, {
379
+ offerRef: 'demo:coupon:1001',
380
+ externalMemberRef: 'member-ref:u_1',
381
+ idempotencyKey: `owned-intent-${content}`,
382
+ });
383
+ const listEntitlements = vi.spyOn(service, 'listEntitlements');
384
+ const searchOffers = vi.spyOn(service, 'searchOffers');
385
+ mockAllSlotsPersist(service);
386
+ const ctx = createPromotionTestContext();
387
+ const handler = createPromotionIntentHandler(ctx);
388
+
389
+ const result = await handler.handle('intent', { content }, ctx, testRequestContext());
390
+
391
+ expect(listEntitlements).toHaveBeenCalledWith(expect.anything(), { limit: 50 });
392
+ expect(searchOffers).not.toHaveBeenCalled();
393
+ expect(result.text).toBe('你的券包里有 1 张优惠券');
394
+ expect(result.cards).toHaveLength(1);
395
+ expect(result.cards[0]?.cardType).toBe('promotion-entitlement-list');
396
+ expect(promotionEntitlementListCardDataSchema.safeParse(result.cards[0]?.data).success).toBe(true);
397
+ expect(result.cards[0]?.data).toMatchObject({
398
+ items: [expect.objectContaining({ title: '满 100 减 20 元券', status: 'EFFECTIVE' })],
399
+ });
400
+ },
401
+ );
402
+
403
+ it('“有什么优惠可以领取”直达可领取券列表,不查询已领取券包', async () => {
404
+ const service = new InMemoryPromotionService();
405
+ const listEntitlements = vi.spyOn(service, 'listEntitlements');
406
+ const available = await service.getOfferDetail(q, { offerRef: 'demo:coupon:1001' });
407
+ vi.spyOn(service, 'searchOffers').mockResolvedValue({
408
+ items: [
409
+ ...Array.from({ length: 6 }, (_, index) => ({ ...available!, offerRef: `available-${index}` })),
410
+ { ...available!, offerRef: 'sold-out', availability: { ...available!.availability, status: 'UNAVAILABLE' } },
411
+ ],
412
+ hasMore: false,
413
+ });
414
+ const searchOffers = vi.mocked(service.searchOffers);
415
+ mockAllSlotsPersist(service);
416
+ const ctx = createPromotionTestContext();
417
+ const handler = createPromotionIntentHandler(ctx);
418
+
419
+ const result = await handler.handle('intent', { content: '有什么优惠可以领取?帮我找找可领的优惠券' }, ctx, testRequestContext());
420
+
421
+ expect(searchOffers).toHaveBeenCalledWith(expect.anything(), { offerTypes: ['COUPON'], limit: 50 });
422
+ expect(listEntitlements).not.toHaveBeenCalled();
423
+ expect(result.cards[0]?.cardType).toBe('promotion-offer-list');
424
+ expect(result.text).toContain('可领取优惠券');
425
+ expect(result.text).toContain('6 张');
426
+ expect(result.cards[0]?.data).toMatchObject({ items: expect.any(Array) });
427
+ expect((result.cards[0]?.data as { items: unknown[] }).items).toHaveLength(6);
428
+ });
429
+
430
+ it('纯券搜索统一投影为券列表,避免回落到活动与优惠卡', async () => {
431
+ const service = new InMemoryPromotionService();
432
+ vi.spyOn(service, 'searchOffers').mockResolvedValue({
433
+ items: [
434
+ {
435
+ ...(await service.getOfferDetail(q, { offerRef: 'demo:coupon:1001' }))!,
436
+ offerType: 'COUPON',
437
+ },
438
+ ],
439
+ hasMore: false,
440
+ });
441
+ mockAllSlotsPersist(service);
442
+ const ctx = createPromotionTestContext();
443
+ const handler = createPromotionIntentHandler(ctx);
444
+
445
+ const result = await handler.handle('intent', { content: '有没有餐饮团购套餐?' }, ctx, testRequestContext());
446
+
447
+ expect(result.cards[0]).toMatchObject({
448
+ cardType: 'promotion-offer-list',
449
+ data: { presentation: 'coupon' },
450
+ });
451
+ });
452
+ });
453
+
454
+ describe('promotion 卡片导航闭环', () => {
455
+ it('活动列表动作进入活动详情,不回到活动列表', async () => {
456
+ const service = new InMemoryPromotionService();
457
+ mockAllSlotsPersist(service);
458
+ const ctx = createPromotionTestContext();
459
+ const handler = createPromotionIntentHandler(ctx);
460
+ const list = await handler.handle('intent', { content: '最近有什么营销活动?' }, ctx, testRequestContext());
461
+ const item = (list.cards[0]?.data as {
462
+ items: { title: string; activityRef: string; itemActions: { value: string }[] }[];
463
+ }).items[0]!;
464
+
465
+ expect(item.itemActions[0]!.value).not.toContain(item.activityRef);
466
+ expectPromotionIntentRoutable(item.itemActions[0]!.value);
467
+ const detail = await handler.handle('intent', { content: item.itemActions[0]!.value }, ctx, testRequestContext());
468
+
469
+ expect(detail.cards).toHaveLength(1);
470
+ expect(detail.cards[0]).toMatchObject({
471
+ cardType: 'promotion-activity-detail',
472
+ data: { title: item.title, presentation: 'detail' },
473
+ });
474
+ });
475
+
476
+ it('厂商活动详情失败时仍进入详情卡,并引导到支付宝查看完整安排', async () => {
477
+ const service = new InMemoryPromotionService();
478
+ const listActivities = service.listActivities.bind(service);
479
+ service.listActivities = vi.fn(async (q, input) => {
480
+ const page = await listActivities(q, input);
481
+ // F5:报名深链为 provider 出参快照(数据驱动),模块不再拼装
482
+ return {
483
+ ...page,
484
+ items: page.items.map((item) => ({
485
+ ...item,
486
+ activityRef: 'demo-act:3001',
487
+ registrationUrl: 'alipays://platformapi/startapp?appId=demo&page=v%2Fmarketing%2Factivity%2Fdetail%3Fid%3Ddemo-activity',
488
+ })),
489
+ };
490
+ });
491
+ service.getActivityDetail = vi.fn(async () => {
492
+ throw new Error('用户token错误或已过期');
493
+ });
494
+ mockAllSlotsPersist(service);
495
+ const ctx = createPromotionTestContext();
496
+ const handler = createPromotionIntentHandler(ctx);
497
+ const list = await handler.handle('intent', { content: '最近有什么营销活动?' }, ctx, testRequestContext());
498
+ const item = (list.cards[0]?.data as {
499
+ items: { title: string; itemActions: { value: string }[] }[];
500
+ }).items[0]!;
501
+
502
+ const result = await handler.handle('intent', { content: item.itemActions[0]!.value }, ctx, testRequestContext());
503
+
504
+ expect(result.text).toBe('这是「周末亲子市集」的活动概览,可前往支付宝查看完整安排并报名');
505
+ expect(result.cards[0]).toMatchObject({
506
+ cardType: 'promotion-activity-detail',
507
+ data: {
508
+ title: item.title,
509
+ presentation: 'detail',
510
+ notice: '完整活动安排请前往支付宝活动页查看',
511
+ sessions: [],
512
+ },
513
+ });
514
+ expect((result.cards[0]!.data as { registrationUrl?: string }).registrationUrl).toBe(
515
+ 'alipays://platformapi/startapp?appId=demo&page=v%2Fmarketing%2Factivity%2Fdetail%3Fid%3Ddemo-activity',
516
+ );
517
+ });
518
+
519
+ it('券列表动作进入优惠券详情卡并消费详情接口结果', async () => {
520
+ const service = new InMemoryPromotionService();
521
+ mockAllSlotsPersist(service);
522
+ const ctx = createPromotionTestContext();
523
+ const handler = createPromotionIntentHandler(ctx);
524
+ const list = await handler.handle('intent', { content: '现在有什么优惠?' }, ctx, testRequestContext());
525
+ const item = (list.cards[0]?.data as {
526
+ items: { title: string; offerRef: string; itemActions: { value: string }[] }[];
527
+ }).items[0]!;
528
+
529
+ expect(item.itemActions[0]!.value).not.toContain(item.offerRef);
530
+ expectPromotionIntentRoutable(item.itemActions[0]!.value);
531
+ const detail = await handler.handle('intent', { content: item.itemActions[0]!.value }, ctx, testRequestContext());
532
+
533
+ expect(detail.cards[0]).toMatchObject({
534
+ cardType: 'promotion-offer-detail',
535
+ data: { presentation: 'offer' },
536
+ });
537
+ expect((detail.cards[0]!.data as { title: string }).title).toBe(item.title);
538
+ });
539
+
540
+ it('已领券的详情提问进入我的券单条卡(详情由行内浮层承载)', async () => {
541
+ const service = new InMemoryPromotionService();
542
+ await service.acquire(q, {
543
+ offerRef: 'demo:coupon:1001',
544
+ externalMemberRef: 'member-ref:u_1',
545
+ idempotencyKey: 'navigation-entitlement',
546
+ });
547
+ mockAllSlotsPersist(service);
548
+ const ctx = createPromotionTestContext();
549
+ const tools = createPromotionTools({ getCtx: () => ctx });
550
+ const handler = createPromotionIntentHandler(ctx);
551
+ const list = (await tools.listEntitlements.execute!({} as never, createToolExecContext(testRequestContext()) as never)) as {
552
+ cards: { data: { items: { title: string; entitlementRef: string }[] } }[];
553
+ };
554
+ const item = list.cards[0]!.data.items[0]!;
555
+
556
+ // 行上已无发消息动作,但用户直接问某张券仍要能落到卡上
557
+ const query = `查看「${item.title}」这张优惠券的使用详情`;
558
+ expectPromotionIntentRoutable(query);
559
+ const detail = await handler.handle('intent', { content: query }, ctx, testRequestContext());
560
+
561
+ expect(detail.cards[0]).toMatchObject({ cardType: 'promotion-entitlement-list' });
562
+ expect((detail.cards[0] as unknown as { data: { items: unknown[] } }).data.items).toHaveLength(1);
563
+ });
564
+
565
+ it('活动分类动作进入对应分类的活动列表', async () => {
566
+ const service = new InMemoryPromotionService();
567
+ mockAllSlotsPersist(service);
568
+ const ctx = createPromotionTestContext();
569
+ const tools = createPromotionTools({ getCtx: () => ctx });
570
+ const handler = createPromotionIntentHandler(ctx);
571
+ const categories = (await tools.listActivityCategories.execute!({} as never, createToolExecContext(testRequestContext()) as never)) as {
572
+ cards: { data: { items: { name: string; categoryId: string; itemActions: { value: string }[] }[] } }[];
573
+ };
574
+ const category = categories.cards[0]!.data.items[0]!;
575
+
576
+ expect(category.itemActions[0]!.value).not.toContain(category.categoryId);
577
+ expectPromotionIntentRoutable(category.itemActions[0]!.value);
578
+ const list = await handler.handle('intent', { content: category.itemActions[0]!.value }, ctx, testRequestContext());
579
+
580
+ expect(list.cards[0]).toMatchObject({
581
+ cardType: 'promotion-activity-list',
582
+ data: { items: [expect.objectContaining({ categoryName: category.name })] },
583
+ });
584
+ });
585
+ });
586
+
587
+ describe('createPromotionActions.acquire(actions 层闭环)', () => {
588
+ // subject-binding 兼容键仍注册在场(版本偏斜期双通道并存);门禁只消费 member.status
589
+ const binding = { resolveExternalMemberRef: (userId: string) => `member-ref:${userId}` };
590
+ const activeAccount = {
591
+ authState: 'authenticated' as const,
592
+ state: 'active' as const,
593
+ primaryMallCardNo: 'C-1001',
594
+ memberRef: 'member:C-1001',
595
+ observedAt: '2026-08-30T00:00:00.000Z',
596
+ };
597
+ const statusFactory = (outcome: string, account?: unknown) =>
598
+ async () => ({ outcome, ...(account !== undefined ? { account } : {}) });
599
+ // 按键分发:member.status → 门禁工厂;其余(subject-binding 兼容)→ binding
600
+ // providers.resolve 抛 → ports 降级 InMemory 单例(双模缺省路径)
601
+ const ctxWithStatus = (
602
+ outcome = 'active',
603
+ account: unknown = activeAccount,
604
+ logger: { warn: ReturnType<typeof vi.fn> } = { warn: vi.fn() },
605
+ ) =>
606
+ ({
607
+ services: { resolve: (key: string) => (key === 'member.status' ? statusFactory(outcome, account) : binding) },
608
+ logger,
609
+ auth: { getContext: () => undefined },
610
+ providers: { resolve: async () => { throw new Error('no provider'); } },
611
+ }) as never;
612
+ const reqCtx = (userId: string) =>
613
+ ({ identity: { userId, source: 'dev' }, attributes: { mall: { miniAppId: 'app', mallId: 'mall-1' } } }) as never;
614
+
615
+ it('会员门禁:not_enrolled 拦截且不触 promotion 服务(门禁先于幂等占位;深链解耦后恒带卡)', async () => {
616
+ vi.mocked(resolvePromotionAcquisitionPort).mockClear();
617
+ const actions = createPromotionActions(
618
+ ctxWithStatus('not_enrolled', { authState: 'authenticated', state: 'not_enrolled', observedAt: '2026-08-30T00:00:00.000Z' }),
619
+ );
620
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_enroll'));
621
+ expect(result.card?.cardType).toBe('member-enrollment-cta');
622
+ expect(result.card?.data).toMatchObject({ authState: 'authenticated', state: 'not_enrolled' });
623
+ expect(result.card?.data).not.toHaveProperty('enrollUrl');
624
+ expect(result.text).toBe('领取需要会员身份,办理入会后即可领券');
625
+ expect(resolvePromotionAcquisitionPort).not.toHaveBeenCalled();
626
+ });
627
+
628
+ it('会员门禁:凭证轴未认证(合法登录但无会员凭证)→ 统一会员引导文案', async () => {
629
+ const actions = createPromotionActions(
630
+ ctxWithStatus('unauthenticated', { authState: 'unauthenticated', state: 'unknown', observedAt: '2026-08-30T00:00:00.000Z' }),
631
+ );
632
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_unauth'));
633
+ expect(result.text).toBe('领取需要会员身份,请先登录或办理入会后再领券');
634
+ });
635
+
636
+ it('会员门禁:unknown 软文案(不误伤可能是会员的用户,不引导入会)', async () => {
637
+ const actions = createPromotionActions(ctxWithStatus('unknown', undefined));
638
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_unknown'));
639
+ expect(result.text).toContain('暂时无法确认会员状态');
640
+ expect(result.text).not.toContain('办理入会');
641
+ });
642
+
643
+ it('会员门禁:member.status 缺席(member 未装/旧版混装)→ 兼容档文案', async () => {
644
+ const ctx = {
645
+ services: { resolve: (key: string) => (key === 'member.status' ? undefined : binding) },
646
+ logger: { warn: vi.fn() },
647
+ auth: { getContext: () => undefined },
648
+ providers: { resolve: async () => { throw new Error('no provider'); } },
649
+ } as never;
650
+ const actions = createPromotionActions(ctx);
651
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_compat'));
652
+ expect(result.text).toBe('领取需要会员身份,请先登录或办理入会后再领券');
653
+ });
654
+
655
+ it('会员门禁:active 放行并透传真实卡号(primaryMallCardNo 真值,非平台派生引用)', async () => {
656
+ vi.mocked(resolvePromotionOffersPort).mockReset(); vi.mocked(resolvePromotionEntitlementsPort).mockReset(); vi.mocked(resolvePromotionActivitiesPort).mockReset(); vi.mocked(resolvePromotionAcquisitionPort).mockReset();
657
+ const seen: string[] = [];
658
+ const origAcquire = inMemoryPromotion.acquire.bind(inMemoryPromotion);
659
+ (inMemoryPromotion as { acquire: unknown }).acquire = async (
660
+ qi: Parameters<typeof origAcquire>[0],
661
+ ai: Parameters<typeof origAcquire>[1],
662
+ ) => {
663
+ seen.push(ai.externalMemberRef);
664
+ return origAcquire(qi, ai);
665
+ };
666
+ try {
667
+ const actions = createPromotionActions(ctxWithStatus());
668
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_active'));
669
+ expect(result.card?.cardType).toBe('promotion-entitlement-list');
670
+ expect(seen).toEqual(['C-1001']);
671
+ } finally {
672
+ (inMemoryPromotion as { acquire: unknown }).acquire = origAcquire;
673
+ }
674
+ });
675
+
676
+ it('会员门禁:仅 opaque 引用的账户(演示链形态)→ 透传放行', async () => {
677
+ vi.mocked(resolvePromotionOffersPort).mockReset(); vi.mocked(resolvePromotionEntitlementsPort).mockReset(); vi.mocked(resolvePromotionActivitiesPort).mockReset(); vi.mocked(resolvePromotionAcquisitionPort).mockReset();
678
+ const seen: string[] = [];
679
+ const origAcquire = inMemoryPromotion.acquire.bind(inMemoryPromotion);
680
+ (inMemoryPromotion as { acquire: unknown }).acquire = async (
681
+ qi: Parameters<typeof origAcquire>[0],
682
+ ai: Parameters<typeof origAcquire>[1],
683
+ ) => {
684
+ seen.push(ai.externalMemberRef);
685
+ return origAcquire(qi, ai);
686
+ };
687
+ try {
688
+ const actions = createPromotionActions(
689
+ ctxWithStatus('active', { authState: 'authenticated', state: 'active', memberRef: 'demo:member:demo-user', observedAt: '2026-08-30T00:00:00.000Z' }),
690
+ );
691
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_opaque'));
692
+ expect(result.card?.cardType).toBe('promotion-entitlement-list');
693
+ expect(seen).toEqual(['demo:member:demo-user']);
694
+ } finally {
695
+ (inMemoryPromotion as { acquire: unknown }).acquire = origAcquire;
696
+ }
697
+ });
698
+
699
+ it('会员门禁:active 但账户快照缺席(解析器原子性破坏)→ 软失败 + warn 留痕', async () => {
700
+ vi.mocked(resolvePromotionAcquisitionPort).mockClear();
701
+ const warn = vi.fn();
702
+ // 内联构造:ctxWithStatus 第二参有默认值(显式 undefined 仍取 activeAccount),
703
+ // 无法表达「account 键缺席」形态
704
+ const ctx = {
705
+ services: {
706
+ resolve: (key: string) =>
707
+ key === 'member.status' ? async () => ({ outcome: 'active' }) : binding,
708
+ },
709
+ logger: { warn },
710
+ auth: { getContext: () => undefined },
711
+ providers: { resolve: async () => { throw new Error('no provider'); } },
712
+ } as never;
713
+ const actions = createPromotionActions(ctx);
714
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_noaccount'));
715
+ expect(result.card).toBeUndefined();
716
+ expect(result.text).toContain('暂时无法确认会员状态');
717
+ expect(warn).toHaveBeenCalledTimes(1);
718
+ expect(resolvePromotionAcquisitionPort).not.toHaveBeenCalled();
719
+ });
720
+
721
+ it('会员门禁:active 但双引用缺失 → 软失败 + warn 留痕(不触 promotion 服务)', async () => {
722
+ vi.mocked(resolvePromotionAcquisitionPort).mockClear();
723
+ const warn = vi.fn();
724
+ const actions = createPromotionActions(
725
+ ctxWithStatus('active', { authState: 'authenticated', state: 'active', observedAt: '2026-08-30T00:00:00.000Z' }, { warn }),
726
+ );
727
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_gate_noref'));
728
+ expect(result.card).toBeUndefined();
729
+ expect(result.text).toContain('暂时无法确认会员状态');
730
+ expect(warn).toHaveBeenCalledTimes(1);
731
+ expect(resolvePromotionAcquisitionPort).not.toHaveBeenCalled();
732
+ });
733
+
734
+ it('评审修复(#4):厂商异常释放幂等占位 → 重试可成功(不固化 24h 拒绝)', async () => {
735
+ vi.mocked(resolvePromotionOffersPort).mockReset(); vi.mocked(resolvePromotionEntitlementsPort).mockReset(); vi.mocked(resolvePromotionActivitiesPort).mockReset(); vi.mocked(resolvePromotionAcquisitionPort).mockReset();
736
+ const actions = createPromotionActions(ctxWithStatus());
737
+ const origAcquire = inMemoryPromotion.acquire.bind(inMemoryPromotion);
738
+ let failNext = true;
739
+ (inMemoryPromotion as { acquire: unknown }).acquire = async (
740
+ qi: Parameters<typeof origAcquire>[0],
741
+ ai: Parameters<typeof origAcquire>[1],
742
+ ) => {
743
+ if (failNext) {
744
+ failNext = false;
745
+ throw new Error('gateway timeout');
746
+ }
747
+ return origAcquire(qi, ai);
748
+ };
749
+ const payload = { offerRef: 'demo:coupon:1001' };
750
+ try {
751
+ // 第一次:厂商异常 → 可重试文案(无卡,经 tbox:notice 下发;占位已释放,不 commit)
752
+ const first = await actions.acquire.execute!(payload, reqCtx('u_flaky'));
753
+ expect(first.card).toBeUndefined();
754
+ expect(first.text).toContain('请稍后重试');
755
+ // 第二次:恢复 → 占位不阻塞,领取成功后直接出「我的券」
756
+ const second = await actions.acquire.execute!(payload, reqCtx('u_flaky'));
757
+ expect(second.card?.cardType).toBe('promotion-entitlement-list');
758
+ } finally {
759
+ (inMemoryPromotion as { acquire: unknown }).acquire = origAcquire;
760
+ }
761
+ });
762
+
763
+ it('重复点击不在模块层拦截,是否允许由 Provider 返回控制', async () => {
764
+ const acquire = vi.fn()
765
+ .mockResolvedValueOnce({ operationRef: 'op-1', offerRef: 'demo:coupon:1001', status: 'REJECTED', code: '100408', message: '领取失败,请稍后重试', observedAt: '2026-09-04T00:00:00.000Z' })
766
+ .mockResolvedValueOnce({ operationRef: 'op-2', offerRef: 'demo:coupon:1001', status: 'SUCCEEDED', observedAt: '2026-09-04T00:00:01.000Z' });
767
+ mockAllSlotsPersist({ acquire, listEntitlements: vi.fn(async () => ({ items: [] })) });
768
+ const warn = vi.fn();
769
+ const actions = createPromotionActions(ctxWithStatus('active', activeAccount, { warn }));
770
+ const payload = { offerRef: 'demo:coupon:1001' };
771
+ const first = await actions.acquire.execute!(payload, reqCtx('u_repeat'));
772
+ const second = await actions.acquire.execute!(payload, reqCtx('u_repeat'));
773
+ expect(first.text).toBe('领取失败:领取失败,请稍后重试(code 100408)');
774
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('code=100408'));
775
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('message="领取失败,请稍后重试"'));
776
+ expect(second.text).toContain('领取成功');
777
+ expect(acquire).toHaveBeenCalledTimes(2);
778
+ expect(acquire.mock.calls[0]![1].idempotencyKey).not.toBe(acquire.mock.calls[1]![1].idempotencyKey);
779
+ });
780
+
781
+ it('未枚举的业务 message 和 code 也原样返回并记录', async () => {
782
+ const acquire = vi.fn(async () => ({
783
+ operationRef: 'op-unlisted', offerRef: 'demo:coupon:1001', status: 'REJECTED',
784
+ code: 'BIZ_UNLISTED', message: '上游自定义拒绝原因', observedAt: '2026-09-04T00:00:00.000Z',
785
+ }));
786
+ mockAllSlotsPersist({ acquire });
787
+ const warn = vi.fn();
788
+ const actions = createPromotionActions(ctxWithStatus('active', activeAccount, { warn }));
789
+
790
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtx('u_unlisted'));
791
+
792
+ expect(result.text).toBe('领取失败:上游自定义拒绝原因(code BIZ_UNLISTED)');
793
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('code=BIZ_UNLISTED'));
794
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('message="上游自定义拒绝原因"'));
795
+ });
796
+ });
797
+
798
+ describe("M1' 承接:券详情收口到列表浮层", () => {
799
+ it('listEntitlements 出我的券卡且不再带 itemActions(详情由行内浮层承载)', async () => {
800
+ const service = new InMemoryPromotionService();
801
+ await service.acquire(q, { offerRef: 'demo:coupon:1001', externalMemberRef: 'm', idempotencyKey: 'm1-ent' });
802
+ mockAllSlotsPersist(service);
803
+ const ctxFake = {
804
+ auth: {
805
+ getContext: () => ({
806
+ identity: { userId: 'u_1', source: 'dev' },
807
+ attributes: { mall: { miniAppId: 'app', mallId: 'mall-1' } },
808
+ }),
809
+ },
810
+ } as never;
811
+ const tools = createPromotionTools({ getCtx: () => ctxFake });
812
+ const result = (await tools.listEntitlements.execute!({} as never, createToolExecContext(testRequestContext()) as never)) as {
813
+ cards: { cardType: string; data: { items: { title: string; itemActions?: unknown[] }[] } }[];
814
+ };
815
+ expect(result.cards[0].cardType).toBe('promotion-entitlement-list');
816
+ expect(promotionEntitlementListCardDataSchema.safeParse(result.cards[0].data).success).toBe(true);
817
+ // 券码与使用说明改由行内浮层展示,行上不再挂发消息动作——
818
+ // 那句话原本指向 getCouponDetail,工具撤掉后留着就是死路由
819
+ expect(result.cards[0].data.items[0].itemActions).toBeUndefined();
820
+ });
821
+
822
+ it('券专属详情工具已撤,通用优惠详情仍在', () => {
823
+ const ctxFake = { auth: { getContext: () => null } } as never;
824
+ const tools = createPromotionTools({ getCtx: () => ctxFake }) as Record<string, { description?: string }>;
825
+ expect(tools.getCouponDetail).toBeUndefined();
826
+ expect(tools.getOfferDetail.description).toContain('通用优惠详情卡');
827
+ });
828
+ });
829
+
830
+ describe('会员门禁(TIER1 门禁闭环,SPEC-3)', () => {
831
+ const statusOf = (outcome: string, extra: Record<string, unknown> = {}) => async () => ({ outcome, ...extra });
832
+ const ctxWithGate = (factory: unknown) =>
833
+ ({ services: { resolve: (key: string) => (key === 'member.status' ? factory : undefined) } }) as never;
834
+ const reqCtxOf = (userId = 'u_gate') =>
835
+ ({ identity: { userId, source: 'dev' as const }, attributes: { mall: { miniAppId: 'app', mallId: 'mall-1' } } }) as never;
836
+ const execCtx = (userId = 'u_gate') => createToolExecContext(reqCtxOf(userId)) as never;
837
+
838
+ it('listEntitlements:not_enrolled + enrollUrl → 出 member-enrollment-cta 且 found:false', async () => {
839
+ const service = new InMemoryPromotionService();
840
+ mockAllSlotsPersist(service);
841
+ const tools = createPromotionTools({
842
+ getCtx: () => ctxWithGate(statusOf('not_enrolled', { enrollUrl: 'alipays://enroll', loginUrl: 'alipays://login' })),
843
+ });
844
+ const result = (await tools.listEntitlements.execute!({} as never, execCtx())) as {
845
+ found: boolean;
846
+ cards?: { cardType: string; data: Record<string, unknown> }[];
847
+ noResultsReason?: string;
848
+ };
849
+ expect(result.found).toBe(false);
850
+ expect(result.cards?.[0]?.cardType).toBe('member-enrollment-cta');
851
+ expect(result.cards?.[0]?.data).toMatchObject({
852
+ userId: 'u_gate',
853
+ authState: 'authenticated',
854
+ state: 'not_enrolled',
855
+ enrollUrl: 'alipays://enroll',
856
+ });
857
+ expect(result.noResultsReason).toBe('完成入会后即可使用该服务');
858
+ });
859
+
860
+ it('listCoupons(OWNED):not_enrolled → 出 member-enrollment-cta(entitlementFacts 单点覆盖两工具)', async () => {
861
+ const service = new InMemoryPromotionService();
862
+ mockAllSlotsPersist(service);
863
+ const tools = createPromotionTools({
864
+ getCtx: () => ctxWithGate(statusOf('not_enrolled', { enrollUrl: 'alipays://enroll' })),
865
+ });
866
+ const result = (await tools.listCoupons.execute!({ mode: 'OWNED' } as never, execCtx())) as {
867
+ cards?: { cardType: string }[];
868
+ };
869
+ expect(result.cards?.[0]?.cardType).toBe('member-enrollment-cta');
870
+ });
871
+
872
+ it('listCoupons(CLAIMABLE):note 契约——含数量关键事实(W7 抽样断言)', async () => {
873
+ const service = new InMemoryPromotionService();
874
+ mockAllSlotsPersist(service);
875
+ const tools = createPromotionTools({ getCtx: () => ctxWithGate(undefined) });
876
+ const result = (await tools.listCoupons.execute!({} as never, execCtx())) as {
877
+ items?: unknown[];
878
+ cards?: { cardType: string; note: string }[];
879
+ };
880
+ if ((result.items?.length ?? 0) > 0) {
881
+ expect(result.cards?.[0]?.cardType).toBe('promotion-offer-list');
882
+ expect(result.cards![0]!.note).toContain('可领优惠券');
883
+ expect(result.cards![0]!.note).toContain(`${result.items!.length} 张`);
884
+ }
885
+ });
886
+
887
+ it('listEntitlements:member 未装(工厂缺席)→ fail-open 照常(InMemory 空券包文案)', async () => {
888
+ const service = new InMemoryPromotionService();
889
+ mockAllSlotsPersist(service);
890
+ const tools = createPromotionTools({ getCtx: () => ctxWithGate(undefined) });
891
+ const result = (await tools.listEntitlements.execute!({} as never, execCtx())) as {
892
+ cards?: { cardType: string }[];
893
+ noResultsReason?: string;
894
+ };
895
+ // 无门禁:InMemory 空券包 → 无卡 + 引导找可领优惠
896
+ expect(result.cards).toBeUndefined();
897
+ expect(result.noResultsReason).toContain('还没有优惠券');
898
+ });
899
+
900
+ it('listEntitlements:unknown → 软放行(不误伤瞬时故障的会员)', async () => {
901
+ const service = new InMemoryPromotionService();
902
+ mockAllSlotsPersist(service);
903
+ const tools = createPromotionTools({ getCtx: () => ctxWithGate(statusOf('unknown')) });
904
+ const result = (await tools.listEntitlements.execute!({} as never, execCtx())) as {
905
+ cards?: { cardType: string }[];
906
+ };
907
+ expect(result.cards).toBeUndefined();
908
+ });
909
+
910
+ it('getOfferDetail:entitlementRef + not_enrolled → 拦截;keyword + not_enrolled → 不拦', async () => {
911
+ const service = new InMemoryPromotionService();
912
+ const getOfferDetail = vi.spyOn(service, 'getOfferDetail');
913
+ mockAllSlotsPersist(service);
914
+ const tools = createPromotionTools({
915
+ getCtx: () => ctxWithGate(statusOf('not_enrolled', { enrollUrl: 'alipays://enroll' })),
916
+ });
917
+ // entitlementRef 反查 = 我的券详情(属人):拦
918
+ const blocked = (await tools.getOfferDetail.execute!({ entitlementRef: 'ent-1' } as never, execCtx())) as {
919
+ found: boolean;
920
+ cards?: { cardType: string }[];
921
+ };
922
+ expect(blocked.found).toBe(false);
923
+ expect(blocked.cards?.[0]?.cardType).toBe('member-enrollment-cta');
924
+ expect(getOfferDetail).not.toHaveBeenCalled();
925
+ // keyword 公共反查:不拦
926
+ const pass = (await tools.getOfferDetail.execute!({ keyword: '团购' } as never, execCtx())) as {
927
+ found: boolean;
928
+ cards?: { cardType: string }[];
929
+ };
930
+ expect(pass.found).toBe(true);
931
+ expect(getOfferDetail).toHaveBeenCalled();
932
+ });
933
+
934
+ it('意图 list-entitlements:not_enrolled → 零 LLM 直出 {cards:[cta], text:notice}', async () => {
935
+ const service = new InMemoryPromotionService();
936
+ const listEntitlements = vi.spyOn(service, 'listEntitlements');
937
+ mockAllSlotsPersist(service);
938
+ const ctx = ctxWithGate(statusOf('not_enrolled', { enrollUrl: 'alipays://enroll' }));
939
+ const handler = createPromotionIntentHandler(ctx);
940
+ const result = await handler.handle('intent', { content: '我的优惠券' }, ctx, reqCtxOf('u_h_gate'));
941
+ expect(listEntitlements).not.toHaveBeenCalled();
942
+ expect(result.cards?.[0]?.cardType).toBe('member-enrollment-cta');
943
+ expect(result.text).toBe('完成入会后即可使用该服务');
944
+ });
945
+
946
+ it('意图 entitlement-detail:not_enrolled → 直出 {cards:[cta], text:notice}', async () => {
947
+ const service = new InMemoryPromotionService();
948
+ mockAllSlotsPersist(service);
949
+ const ctx = ctxWithGate(statusOf('not_enrolled', { enrollUrl: 'alipays://enroll' }));
950
+ const handler = createPromotionIntentHandler(ctx);
951
+ const result = await handler.handle('intent', { content: '查看「满 100 减 20 元券」这张优惠券的使用详情' }, ctx, reqCtxOf('u_h_gate2'));
952
+ expect(result.cards?.[0]?.cardType).toBe('member-enrollment-cta');
953
+ });
954
+
955
+ it('意图 list-entitlements:active → 全量回归(券包正常直出)', async () => {
956
+ const service = new InMemoryPromotionService();
957
+ await service.acquire(q, { offerRef: 'demo:coupon:1001', externalMemberRef: 'm', idempotencyKey: 'gate-active-1' });
958
+ mockAllSlotsPersist(service);
959
+ const ctx = ctxWithGate(statusOf('active', { account: { authState: 'authenticated', state: 'active' } }));
960
+ const handler = createPromotionIntentHandler(ctx);
961
+ const result = await handler.handle('intent', { content: '我的优惠券' }, ctx, reqCtxOf('u_1'));
962
+ expect(result.cards?.[0]?.cardType).toBe('promotion-entitlement-list');
963
+ expect(result.text).toContain('你的券包里有 1 张优惠券');
964
+ });
965
+
966
+ it('acquire blocked:not_enrolled + enrollUrl → 附卡且文案仍为自有八态文案(fail-closed 语义)', async () => {
967
+ vi.mocked(resolvePromotionAcquisitionPort).mockClear();
968
+ const actions = createPromotionActions(
969
+ ctxWithGate(statusOf('not_enrolled', { enrollUrl: 'alipays://enroll' })),
970
+ );
971
+ const result = await actions.acquire.execute!({ offerRef: 'demo:coupon:1001' }, reqCtxOf('u_gate_card'));
972
+ expect(result.card?.cardType).toBe('member-enrollment-cta');
973
+ expect(result.text).toBe('领取需要会员身份,办理入会后即可领券'); // 文案表零改动
974
+ expect(resolvePromotionAcquisitionPort).not.toHaveBeenCalled();
975
+ });
976
+ });