@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.
- package/AGENTS.md +16 -0
- package/README.md +90 -11
- package/dist/chunk-QOTX4XDS.js +1745 -0
- package/dist/chunk-S7CVFCML.js +1330 -0
- package/dist/client/index.d.ts +21 -3
- package/dist/client/index.js +4 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +16 -4
- package/dist/promotion-6L7HLRC2.css +2135 -0
- package/dist/server/index.d.ts +2481 -41
- package/dist/server/index.js +13 -3
- package/package.json +28 -15
- package/skills/promotion/SKILL.md +137 -0
- package/skills/promotion/references/claim-loop.md +18 -0
- package/src/client/cards/index.ts +10 -0
- package/src/client/cards/promotion-activity-category-list/index.tsx +34 -0
- package/src/client/cards/promotion-activity-detail/index.tsx +84 -0
- package/src/client/cards/promotion-activity-list/index.tsx +56 -0
- package/src/client/cards/promotion-best-deal/index.tsx +18 -0
- package/src/client/cards/promotion-entitlement-list/index.tsx +161 -0
- package/src/client/cards/promotion-offer-detail/index.tsx +116 -0
- package/src/client/cards/promotion-offer-list/index.tsx +283 -0
- package/src/client/cards/view.ts +72 -0
- package/src/client/components/CouponQrCode.tsx +48 -0
- package/src/client/components/CouponSheet.tsx +124 -0
- package/src/client/components/ImageWithFallback.tsx +39 -0
- package/src/client/index.ts +23 -4
- package/src/client/styles/promotion.css +2135 -0
- package/src/client/types/css.d.ts +2 -0
- package/src/client/utils/sanitize-activity-html.ts +48 -0
- package/src/client/utils/scroll-card-bottom.ts +40 -0
- package/src/server/actions.ts +217 -0
- package/src/server/cards/index.ts +19 -0
- package/src/server/cards/promotion-activity-category-list/meta.ts +13 -0
- package/src/server/cards/promotion-activity-detail/meta.ts +29 -0
- package/src/server/cards/promotion-activity-list/meta.ts +13 -0
- package/src/server/cards/promotion-best-deal/meta.ts +20 -0
- package/src/server/cards/promotion-entitlement-list/meta.ts +17 -0
- package/src/server/cards/promotion-entitlement-list/samples.ts +51 -0
- package/src/server/cards/promotion-offer-detail/meta.ts +25 -0
- package/src/server/cards/promotion-offer-list/meta.ts +20 -0
- package/src/server/cards/promotion-offer-list/samples.ts +154 -0
- package/src/server/handler.ts +378 -16
- package/src/server/index.ts +117 -22
- package/src/server/navigation.ts +23 -0
- package/src/server/ports.ts +56 -0
- package/src/server/service.ts +243 -24
- package/src/server/tools.ts +789 -0
- package/tbox.module.json +126 -0
- package/tests/cards-render.test.tsx +656 -0
- package/tests/ports-resolve.test.ts +96 -0
- package/tests/pretool-coupons.test.ts +63 -0
- package/tests/promotion.test.ts +976 -0
- package/tests/samples.test.ts +42 -0
- package/tests/view.test.ts +61 -0
- package/tsup.config.ts +9 -1
- package/dist/chunk-AD6OLHSM.js +0 -86
- package/dist/chunk-LQGF6337.js +0 -32
- package/src/client/cards/coupon/index.tsx +0 -12
- package/src/server/cards/coupon/meta.ts +0 -23
- package/tbox.component.json +0 -20
- package/tests/service.test.ts +0 -31
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promotion-offer-list 附加预览样本(沉淀自 mall dev-gallery 优惠券链路画廊)。
|
|
3
|
+
*
|
|
4
|
+
* 方法论(迁移自画廊维护约定):
|
|
5
|
+
* - 覆盖基准是组件实际读取的 data 分支(多券主态/展开收起/缺图长文案/滤掉不可领/空态/
|
|
6
|
+
* 定向单张),而非 sampleData 的形状;
|
|
7
|
+
* - 视觉等价的输入合并为一格,note 列全合并口径;
|
|
8
|
+
* - 样本即契约:data 必须 dataSchema 校验通过且无 shape 外键(tests/samples.test.ts 双断言);
|
|
9
|
+
* - 券图为内联 SVG 占位(画廊时期用本地 PNG 位图;模块样本走无网络依赖的 SVG——
|
|
10
|
+
* 验证的是行高/截断/按钮对齐与缺图兜底,不是图案还原)。
|
|
11
|
+
*/
|
|
12
|
+
import type { CardSample } from '@tbox.cn/app-contracts';
|
|
13
|
+
import type { PromotionOfferListCardData } from '@tbox.cn/app-contracts-mall';
|
|
14
|
+
|
|
15
|
+
/** 券图占位:中性色块 + 品类色角标(咖啡棕/零售蓝/餐饮红——区分行间差异即可) */
|
|
16
|
+
function couponImage(accent: string): string {
|
|
17
|
+
return 'data:image/svg+xml;utf8,'
|
|
18
|
+
+ '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180">'
|
|
19
|
+
+ '<rect width="240" height="180" rx="12" fill="%23f2f4f7"/>'
|
|
20
|
+
+ `<rect x="0" y="0" width="240" height="52" rx="12" fill="%23${accent}" opacity="0.85"/>`
|
|
21
|
+
+ '<rect x="20" y="76" width="140" height="16" rx="4" fill="%23d5dae2"/>'
|
|
22
|
+
+ '<rect x="20" y="104" width="96" height="12" rx="3" fill="%23e3e7ed"/>'
|
|
23
|
+
+ '<circle cx="196" cy="128" r="26" fill="none" stroke="%23c6ccd6" stroke-width="4" stroke-dasharray="6 6"/>'
|
|
24
|
+
+ '</svg>';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IMG = {
|
|
28
|
+
coffee: couponImage('8a5a3b'),
|
|
29
|
+
lifestyle: couponImage('3d5a80'),
|
|
30
|
+
dining: couponImage('9e2b25'),
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/** 浮层无令牌时的兜底动作(真机 provider 下发的就是「查看详情」的发消息动作)。
|
|
34
|
+
* 行上的领/抢按钮不读它——那是 claimActionLabel,见样本各条。 */
|
|
35
|
+
const claimAction = (label: string, title: string) => [{
|
|
36
|
+
kind: 'send' as const,
|
|
37
|
+
label,
|
|
38
|
+
value: `我要${label}「${title}」`,
|
|
39
|
+
}];
|
|
40
|
+
|
|
41
|
+
const claimableOffers = [
|
|
42
|
+
{
|
|
43
|
+
offerRef: 'offer:coffee-30',
|
|
44
|
+
title: '瑞幸咖啡(杭州拱墅示例店)|30元饮品代金券',
|
|
45
|
+
subtitle: '1F · 到店点单可用 · 爆卖674万+',
|
|
46
|
+
offerType: 'COUPON',
|
|
47
|
+
benefitText: '¥30',
|
|
48
|
+
priceText: '免费',
|
|
49
|
+
originalPriceText: '¥30',
|
|
50
|
+
claimable: true,
|
|
51
|
+
availabilityStatus: 'AVAILABLE',
|
|
52
|
+
validityText: '领取后 30 天内有效',
|
|
53
|
+
imageUrl: IMG.coffee,
|
|
54
|
+
tags: ['饮品'],
|
|
55
|
+
itemActions: claimAction('领取', '30元饮品代金券'),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
offerRef: 'offer:lifestyle-120',
|
|
59
|
+
title: 'KKV 120元优惠券',
|
|
60
|
+
subtitle: '2F · 全场精选商品可用',
|
|
61
|
+
offerType: 'COUPON',
|
|
62
|
+
benefitText: '¥105 · 8.8折',
|
|
63
|
+
priceText: '¥105',
|
|
64
|
+
originalPriceText: '¥120',
|
|
65
|
+
discountLabel: '8.8折',
|
|
66
|
+
claimable: true,
|
|
67
|
+
availabilityStatus: 'AVAILABLE',
|
|
68
|
+
validityText: '有效期至 2026-09-30',
|
|
69
|
+
imageUrl: IMG.lifestyle,
|
|
70
|
+
tags: ['零售'],
|
|
71
|
+
claimActionLabel: '抢购',
|
|
72
|
+
itemActions: claimAction('抢购', 'KKV 120元优惠券'),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
offerRef: 'offer:dining-30',
|
|
76
|
+
title: '南京大牌档|招牌菜品30元无门槛券',
|
|
77
|
+
subtitle: '4F · 周末及节假日通用',
|
|
78
|
+
offerType: 'COUPON',
|
|
79
|
+
benefitText: '¥30',
|
|
80
|
+
priceText: '¥28.8',
|
|
81
|
+
originalPriceText: '¥38.8',
|
|
82
|
+
discountLabel: '7.5折',
|
|
83
|
+
stockText: '剩余 29 份',
|
|
84
|
+
usageSceneText: '到店出示券码核销',
|
|
85
|
+
guaranteeText: '随时退 · 过期自动退',
|
|
86
|
+
claimable: true,
|
|
87
|
+
availabilityStatus: 'AVAILABLE',
|
|
88
|
+
validityText: '有效期至 2026-10-08',
|
|
89
|
+
imageUrl: IMG.dining,
|
|
90
|
+
tags: ['餐饮'],
|
|
91
|
+
claimActionLabel: '抢购',
|
|
92
|
+
itemActions: claimAction('抢购', '招牌菜品30元无门槛券'),
|
|
93
|
+
},
|
|
94
|
+
] satisfies NonNullable<PromotionOfferListCardData['items']>;
|
|
95
|
+
|
|
96
|
+
export const samples: CardSample<PromotionOfferListCardData>[] = [
|
|
97
|
+
{
|
|
98
|
+
label: '领券·多券列表',
|
|
99
|
+
note: '商场神券主态:券图、长标题、利益点和查看入口同屏',
|
|
100
|
+
group: '领券列表',
|
|
101
|
+
data: { mode: 'claimable', presentation: 'coupon', summary: '当前商场 · 优惠精选', items: claimableOffers },
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
label: '领券·展开与收起',
|
|
105
|
+
note: 'maxItems 截断 + 透明底部入口与门店导览对齐,文字右侧箭头随展开状态切换',
|
|
106
|
+
group: '领券列表',
|
|
107
|
+
data: { mode: 'claimable', presentation: 'coupon', maxItems: 2, items: claimableOffers },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
label: '领券·缺图与长文案',
|
|
111
|
+
note: '缺图走中性灰兜底(不带色相,免得比真图还抢眼)+ 两行标题截断,不应改变行高与按钮对齐',
|
|
112
|
+
group: '领券列表',
|
|
113
|
+
data: {
|
|
114
|
+
mode: 'claimable', presentation: 'coupon', summary: '部分优惠资格有变化',
|
|
115
|
+
items: [{
|
|
116
|
+
...claimableOffers[0], offerRef: 'offer:no-image', imageUrl: undefined,
|
|
117
|
+
title: '会员限定超长名称测试|仅限本周末指定时段到店消费使用的满减优惠券',
|
|
118
|
+
subtitle: '限周末及节假日 · 每人每日一张', benefitText: '满100减20',
|
|
119
|
+
priceText: '免费', originalPriceText: '¥20',
|
|
120
|
+
itemActions: claimAction('领取', '会员限定满减优惠券'),
|
|
121
|
+
}],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: '领券·滤掉不可领',
|
|
126
|
+
note: '列表只呈现能领的:三条里一条 claimable=false,渲染出来应只有两行——列出领不了的券,用户点一次才知道点不动',
|
|
127
|
+
group: '领券列表',
|
|
128
|
+
data: {
|
|
129
|
+
mode: 'claimable', presentation: 'coupon',
|
|
130
|
+
items: [
|
|
131
|
+
claimableOffers[0],
|
|
132
|
+
{ ...claimableOffers[1], offerRef: 'offer:sold-out', claimable: false, availabilityStatus: 'UNAVAILABLE' },
|
|
133
|
+
claimableOffers[2],
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
label: '领券·空态',
|
|
139
|
+
note: '当前没有可领取优惠时的明确解释',
|
|
140
|
+
group: '领券列表',
|
|
141
|
+
data: { mode: 'claimable', presentation: 'coupon', items: [], noResultsReason: '当前暂无可领取优惠,稍后再来看看' },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
label: '券·定向单张',
|
|
145
|
+
note: '用户问「XX 券的详情」时的落点:出这一张券的券行(mode=search——用户指名要看的搜索态),点开浮层看详情并领取。'
|
|
146
|
+
+ '不像领券态那样滤掉不可领——指名要看的券即便当下领不了也照样呈现',
|
|
147
|
+
group: '领券列表',
|
|
148
|
+
data: {
|
|
149
|
+
mode: 'search',
|
|
150
|
+
presentation: 'coupon',
|
|
151
|
+
items: [{ ...claimableOffers[2], claimable: false, availabilityStatus: 'UNAVAILABLE' }],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
];
|
package/src/server/handler.ts
CHANGED
|
@@ -1,22 +1,384 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* promotion 意图 handler(015 S6:intents patterns 命中出搜索/推荐卡;写走 acquire 动作)。
|
|
3
|
+
*/
|
|
4
|
+
import type { HandlerDefinition, ServerContext } from '@tbox.cn/app-sdk/server';
|
|
5
|
+
import { matchIntent } from '@tbox.cn/app-sdk/server';
|
|
6
|
+
import type { IntentRoute } from '@tbox.cn/app-sdk/server';
|
|
7
|
+
import { resolveMemberGate, toMallQueryContext } from '@tbox.cn/app-contracts-mall';
|
|
8
|
+
import type { MemberStatusFactory, PromotionActivityPort } from '@tbox.cn/app-contracts-mall';
|
|
9
|
+
import {
|
|
10
|
+
resolvePromotionActivitiesPort,
|
|
11
|
+
resolvePromotionEntitlementsPort,
|
|
12
|
+
resolvePromotionOffersPort,
|
|
13
|
+
} from './ports';
|
|
14
|
+
import type { PromotionService } from './service';
|
|
15
|
+
import {
|
|
16
|
+
activityDetail,
|
|
17
|
+
activitySummary,
|
|
18
|
+
entitlementSummary,
|
|
19
|
+
offerDetail,
|
|
20
|
+
offerSummary,
|
|
21
|
+
} from './tools';
|
|
22
|
+
import { parsePromotionNavigationQuery } from './navigation';
|
|
4
23
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
24
|
+
/** 意图路由 patterns(collectIntentRoutes 装配收集;BM25 路由词同源;收窄:删跨域泛词——
|
|
25
|
+
* 「便宜/折扣/团购/市集/展览」独立泛词经 bigram 泄漏致无关长句误入直出卡;「活动中心」
|
|
26
|
+
* 为 QuickCommands 固定句补偿,「活动」子串保留于域内长词以维持卡按钮 value 路由) */
|
|
27
|
+
export const PROMOTION_INTENTS = {
|
|
28
|
+
'list-entitlements': {
|
|
29
|
+
patterns: ['我的优惠券', '我的券包', '我的券', '已领取的券', '已领优惠券', '持有的优惠券'],
|
|
30
|
+
description: '查询当前会员已拥有的优惠券',
|
|
31
|
+
},
|
|
32
|
+
'search-offers': {
|
|
33
|
+
patterns: ['领券', '领取优惠券', '有什么优惠', '现在有什么优惠?'],
|
|
34
|
+
description: '搜索可领优惠',
|
|
35
|
+
},
|
|
36
|
+
'list-activities': {
|
|
37
|
+
patterns: ['营销活动', '有什么活动', '活动中心'],
|
|
38
|
+
description: '查询营销活动',
|
|
39
|
+
},
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/** 二层分类路由(模块级常量——matchIntent WeakMap 身份缓存稳定;patterns 与 PROMOTION_INTENTS 同源) */
|
|
43
|
+
const PROMOTION_RESOLVE_ROUTES: IntentRoute[] = [
|
|
44
|
+
{
|
|
45
|
+
id: 'list-entitlements',
|
|
46
|
+
keywords: [...PROMOTION_INTENTS['list-entitlements'].patterns],
|
|
47
|
+
handlerId: 'promotion',
|
|
48
|
+
action: 'list-entitlements',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'search-offers',
|
|
52
|
+
keywords: [...PROMOTION_INTENTS['search-offers'].patterns],
|
|
53
|
+
handlerId: 'promotion',
|
|
54
|
+
action: 'search-offers',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'list-activities',
|
|
58
|
+
keywords: [...PROMOTION_INTENTS['list-activities'].patterns],
|
|
59
|
+
handlerId: 'promotion',
|
|
60
|
+
action: 'list-activities',
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const OWNED_INTENT_HINTS = ['我的优惠券', '我的券包', '我的券', '已领取', '已领优惠券', '持有的优惠券'];
|
|
65
|
+
const CLAIMABLE_INTENT_HINTS = ['有什么优惠', '可领取', '可领', '领取优惠券', '领券'];
|
|
66
|
+
|
|
67
|
+
function intentContent(payload: unknown): string {
|
|
68
|
+
if (typeof payload === 'string') return payload;
|
|
69
|
+
if (payload && typeof payload === 'object' && 'content' in payload) {
|
|
70
|
+
const content = (payload as { content?: unknown }).content;
|
|
71
|
+
return typeof content === 'string' ? content : '';
|
|
72
|
+
}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type ResolvedIntent =
|
|
77
|
+
| { kind: keyof typeof PROMOTION_INTENTS }
|
|
78
|
+
| { kind: 'search-coupons' }
|
|
79
|
+
| { kind: 'activity-detail'; title: string }
|
|
80
|
+
| { kind: 'offer-detail'; title: string }
|
|
81
|
+
| { kind: 'entitlement-detail'; title: string }
|
|
82
|
+
| { kind: 'activity-category'; name: string };
|
|
83
|
+
|
|
84
|
+
function resolveIntent(payload: unknown): ResolvedIntent | null {
|
|
85
|
+
const content = intentContent(payload);
|
|
86
|
+
const navigation = parsePromotionNavigationQuery(content);
|
|
87
|
+
if (navigation) return navigation;
|
|
88
|
+
// 前缀路由洋葱守卫:查看「 引导句未命中导航模板 = 非本域确定性语义(外层 prefix 误延展)
|
|
89
|
+
// → 返回 null 由 handle 放行 LLM,不以 search-offers 兜底吞轮(bumpin/member/guide 同惯例)。
|
|
90
|
+
if (content.startsWith(NAVIGATION_SENTENCE_PREFIX)) return null;
|
|
91
|
+
// 先处理业务边界,再交给 BM25:可领取与已领取是互斥查询轴,避免“优惠券”宽词把首页入口落到券包。
|
|
92
|
+
const owned = OWNED_INTENT_HINTS.some((hint) => content.includes(hint));
|
|
93
|
+
const claimable = CLAIMABLE_INTENT_HINTS.some((hint) => content.includes(hint));
|
|
94
|
+
if (claimable && !owned) return { kind: 'search-coupons' };
|
|
95
|
+
if (owned) return { kind: 'list-entitlements' };
|
|
96
|
+
// 统一检索层:BM25 竞争决策(替代原 list-activities 优先的双层 includes 翻转);
|
|
97
|
+
// 未命中兜底 search-offers(与既有缺省方向一致)
|
|
98
|
+
const id = matchIntent(PROMOTION_RESOLVE_ROUTES, content)?.id;
|
|
99
|
+
return {
|
|
100
|
+
kind:
|
|
101
|
+
id === 'list-entitlements'
|
|
102
|
+
? 'list-entitlements'
|
|
103
|
+
: id === 'list-activities'
|
|
104
|
+
? 'list-activities'
|
|
105
|
+
: 'search-offers',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveAction(action: string, payload: unknown): ResolvedIntent | null {
|
|
110
|
+
if (action === 'intent') return resolveIntent(payload);
|
|
111
|
+
if (action === 'list-entitlements' || action === 'search-offers' || action === 'list-activities') {
|
|
112
|
+
return { kind: action };
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function offerPresentation(offerType: string) {
|
|
118
|
+
if (offerType === 'PROMOTION') return 'promotion' as const;
|
|
119
|
+
if (offerType === 'BUNDLE') return 'package' as const;
|
|
120
|
+
return 'offer' as const;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function offerListPresentation(items: readonly { offerType: string }[]) {
|
|
124
|
+
return items.length > 0 && items.every((item) => item.offerType === 'COUPON')
|
|
125
|
+
? 'coupon' as const
|
|
126
|
+
: 'offer' as const;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function activityCategoryNames(service: PromotionActivityPort) {
|
|
130
|
+
const categories = (await service.listActivityCategories?.()) ?? [];
|
|
131
|
+
return {
|
|
132
|
+
categories,
|
|
133
|
+
names: new Map(categories.map((item) => [item.categoryId, item.name])),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 卡片导航模板句头(promotionNavigationQuery 四式共享头):确定性前缀路由入口 */
|
|
138
|
+
const NAVIGATION_SENTENCE_PREFIX = '查看「';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 活动意图统一交给 alipay-activity,由该模块按 provider 能力策略投影联单卡。
|
|
142
|
+
* 未启用策略时返回 continueWith,调用方继续执行既有 promotion provider 路径。
|
|
143
|
+
*/
|
|
144
|
+
async function delegateLinkedActivity(
|
|
145
|
+
ctx: ServerContext,
|
|
146
|
+
payload: unknown,
|
|
147
|
+
reqCtx: NonNullable<Parameters<HandlerDefinition['handle']>[3]>,
|
|
148
|
+
) {
|
|
149
|
+
const handler = ctx.handlers.resolve('alipay-activity');
|
|
150
|
+
if (!handler) return undefined;
|
|
151
|
+
const result = await handler.handle('activity-router', payload, ctx, reqCtx);
|
|
152
|
+
return result.continueWith ? undefined : result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createPromotionIntentHandler(ctx: ServerContext): HandlerDefinition {
|
|
7
156
|
return {
|
|
8
157
|
id: 'promotion',
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
158
|
+
// 顶层意图 = 确定性触发器(exact 整句 + prefix 导航模板头):语义分类职责归
|
|
159
|
+
// skill 路由与 LLM 工具编排(BM25 单标签 + 硬短路会截断复合句,如「推荐门店+领券」)。
|
|
160
|
+
// QuickCommands 固定句(现在有什么优惠?/活动中心)随 exact 句面直出。
|
|
161
|
+
intents: [
|
|
162
|
+
...Object.values(PROMOTION_INTENTS).map(({ patterns }) => ({ patterns: [...patterns], match: 'exact' as const })),
|
|
163
|
+
{
|
|
164
|
+
// 卡片导航模板句(「查看「X」…」四式)前缀路由:handler 内
|
|
165
|
+
// parsePromotionNavigationQuery 模板全等校验承接;未命中模板 → 洋葱放行(resolveIntent null)。
|
|
166
|
+
patterns: [NAVIGATION_SENTENCE_PREFIX],
|
|
167
|
+
match: 'prefix',
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
handle: async (action, payload, _ctx, reqCtx) => {
|
|
171
|
+
if (!reqCtx) return { cards: [], text: '营销服务不可用(缺少运行上下文)' };
|
|
172
|
+
const q = toMallQueryContext(reqCtx);
|
|
173
|
+
|
|
174
|
+
const resolved = resolveAction(action, payload);
|
|
175
|
+
if (!resolved) {
|
|
176
|
+
// 洋葱守卫(洋葱定律 1:未识别 = 失败 = 放行不吞——同 bumpin/member/shopping-guide 惯例):
|
|
177
|
+
// prefix 误延展或 action 无法分辨时交还 LLM 主路径(preTool/记忆/Skill 照常),不产卡不产兜底文案。
|
|
178
|
+
return { cards: [], continueWith: { content: intentContent(payload) } };
|
|
179
|
+
}
|
|
180
|
+
switch (resolved.kind) {
|
|
181
|
+
case 'activity-detail': {
|
|
182
|
+
const delegated = await delegateLinkedActivity(ctx, payload, reqCtx);
|
|
183
|
+
if (delegated) return delegated;
|
|
184
|
+
const service = await resolvePromotionActivitiesPort(reqCtx, ctx);
|
|
185
|
+
const page = await service.listActivities(q, { keyword: resolved.title, limit: 10 });
|
|
186
|
+
const summary = page.items.find((item) => item.title === resolved.title) ?? page.items[0];
|
|
187
|
+
if (!summary) return { cards: [], text: `未找到「${resolved.title}」` };
|
|
188
|
+
const { names } = await activityCategoryNames(service);
|
|
189
|
+
const registrationUrl = summary.registrationUrl;
|
|
190
|
+
let detail;
|
|
191
|
+
try {
|
|
192
|
+
detail = await service.getActivityDetail(q, summary.activityRef);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
(ctx as ServerContext & { logger?: { warn(message: string): void } }).logger?.warn(
|
|
195
|
+
`活动详情查询失败,降级展示列表摘要:${(error as Error).message}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const projected = detail
|
|
199
|
+
? activityDetail(detail, 'detail')
|
|
200
|
+
: activityDetail(
|
|
201
|
+
{
|
|
202
|
+
...summary,
|
|
203
|
+
sessions: [],
|
|
204
|
+
guide: {
|
|
205
|
+
steps: [],
|
|
206
|
+
conditions: [],
|
|
207
|
+
notice: registrationUrl
|
|
208
|
+
? '完整活动安排请前往支付宝活动页查看'
|
|
209
|
+
: '活动详细安排暂时无法获取,请稍后重试',
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
'detail',
|
|
213
|
+
);
|
|
214
|
+
return {
|
|
215
|
+
cards: [
|
|
216
|
+
{
|
|
217
|
+
cardType: 'promotion-activity-detail',
|
|
218
|
+
data: {
|
|
219
|
+
...projected,
|
|
220
|
+
...(summary.categoryId && names.get(summary.categoryId)
|
|
221
|
+
? { categoryName: names.get(summary.categoryId) }
|
|
222
|
+
: {}),
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
text: detail
|
|
227
|
+
? `这是「${detail.title}」的活动详情`
|
|
228
|
+
: registrationUrl
|
|
229
|
+
? `这是「${summary.title}」的活动概览,可前往支付宝查看完整安排并报名`
|
|
230
|
+
: `先为你展示「${summary.title}」的活动概览,详细安排暂时无法获取`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
case 'offer-detail': {
|
|
234
|
+
const service = await resolvePromotionOffersPort(reqCtx, ctx);
|
|
235
|
+
const offer = await service.getOfferDetail(q, { keyword: resolved.title });
|
|
236
|
+
if (!offer) return { cards: [], text: `未找到「${resolved.title}」` };
|
|
237
|
+
const stores = await service.listOfferStores?.(q, offer.offerRef);
|
|
238
|
+
return {
|
|
239
|
+
cards: [
|
|
240
|
+
{
|
|
241
|
+
cardType: 'promotion-offer-detail',
|
|
242
|
+
data: offerDetail(offer, (service as PromotionService).supportsAcquisition !== false, offerPresentation(offer.offerType), stores), // 能力位运行期探测
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
text: `这是「${offer.title}」的优惠详情,可查看使用须知并${offer.claimActionLabel === '抢购' ? '前往小程序抢购' : '领取'}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
case 'entitlement-detail': {
|
|
249
|
+
// 券详情不再单独出卡:出这一张券的「我的券」行,券码与使用说明由行内浮层承载
|
|
250
|
+
const gate = await resolveMemberGate(
|
|
251
|
+
ctx.services?.resolve?.('member.status') as MemberStatusFactory | undefined,
|
|
252
|
+
reqCtx,
|
|
253
|
+
);
|
|
254
|
+
if (gate) return { cards: gate.card ? [gate.card] : [], text: gate.notice };
|
|
255
|
+
const service = await resolvePromotionEntitlementsPort(reqCtx, ctx);
|
|
256
|
+
const page = await service.listEntitlements(q, { limit: 50 });
|
|
257
|
+
const summary = page.items.find((item) => item.title === resolved.title)
|
|
258
|
+
?? page.items.find((item) => item.title.includes(resolved.title) || resolved.title.includes(item.title));
|
|
259
|
+
if (!summary) return { cards: [], text: `未在你的券包中找到「${resolved.title}」` };
|
|
260
|
+
return {
|
|
261
|
+
cards: [{ cardType: 'promotion-entitlement-list', data: { mallId: q.scope.mallId, miniAppId: q.scope.miniAppId, items: [entitlementSummary(summary)] } }],
|
|
262
|
+
text: `这是「${summary.title}」,点开可看券码与使用说明`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
case 'activity-category': {
|
|
266
|
+
const delegated = await delegateLinkedActivity(ctx, payload, reqCtx);
|
|
267
|
+
if (delegated) return delegated;
|
|
268
|
+
const service = await resolvePromotionActivitiesPort(reqCtx, ctx);
|
|
269
|
+
const { categories, names } = await activityCategoryNames(service);
|
|
270
|
+
const category = categories.find((item) => item.name === resolved.name)
|
|
271
|
+
?? categories.find((item) => item.name.includes(resolved.name) || resolved.name.includes(item.name));
|
|
272
|
+
const categoryId = category?.categoryId ?? await (service as PromotionService).resolveCategoryId?.(resolved.name); // 能力口运行期探测
|
|
273
|
+
if (!categoryId) return { cards: [], text: `未找到「${resolved.name}」活动分类` };
|
|
274
|
+
const page = await service.listActivities(q, { categoryId, dataScope: 'CURRENT', limit: 10 });
|
|
275
|
+
if (page.items.length === 0) return { cards: [], text: `近期暂无「${resolved.name}」分类活动` };
|
|
276
|
+
return {
|
|
277
|
+
cards: [
|
|
278
|
+
{
|
|
279
|
+
cardType: 'promotion-activity-list',
|
|
280
|
+
data: {
|
|
281
|
+
items: page.items.map((item) => activitySummary(item, names)),
|
|
282
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
text: `为你找到 ${page.items.length} 场「${resolved.name}」活动`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
case 'search-offers': {
|
|
290
|
+
const service = await resolvePromotionOffersPort(reqCtx, ctx);
|
|
291
|
+
const page = await service.searchOffers(q, {});
|
|
292
|
+
const items = page.items.filter((item) => item.availability.status === 'AVAILABLE');
|
|
293
|
+
if (items.length === 0) return { cards: [], text: '暂无可领优惠' };
|
|
294
|
+
const presentation = offerListPresentation(items);
|
|
295
|
+
return {
|
|
296
|
+
cards: [
|
|
297
|
+
{
|
|
298
|
+
cardType: 'promotion-offer-list',
|
|
299
|
+
data: {
|
|
300
|
+
presentation,
|
|
301
|
+
mode: 'claimable',
|
|
302
|
+
items: items.map(offerSummary),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
text: `为你找到 ${items.length} 条可领优惠`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
case 'search-coupons': {
|
|
310
|
+
const service = await resolvePromotionOffersPort(reqCtx, ctx);
|
|
311
|
+
const page = await service.searchOffers(q, { offerTypes: ['COUPON'], limit: 50 });
|
|
312
|
+
const items = page.items.filter((item) => item.availability.status === 'AVAILABLE');
|
|
313
|
+
if (items.length === 0) return { cards: [], text: '当前商场暂未找到可领取优惠券' };
|
|
314
|
+
return {
|
|
315
|
+
cards: [{ cardType: 'promotion-offer-list', data: {
|
|
316
|
+
presentation: 'coupon', mode: 'claimable', items: items.map(offerSummary),
|
|
317
|
+
} }],
|
|
318
|
+
text: `为你找到 ${items.length} 张可领取优惠券`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
case 'list-entitlements': {
|
|
322
|
+
const gate = await resolveMemberGate(
|
|
323
|
+
ctx.services?.resolve?.('member.status') as MemberStatusFactory | undefined,
|
|
324
|
+
reqCtx,
|
|
325
|
+
);
|
|
326
|
+
if (gate) return { cards: gate.card ? [gate.card] : [], text: gate.notice };
|
|
327
|
+
const service = await resolvePromotionEntitlementsPort(reqCtx, ctx);
|
|
328
|
+
const page = await service.listEntitlements(q, { limit: 50 });
|
|
329
|
+
if (page.items.length === 0) {
|
|
330
|
+
// 空券降级推荐走 offers 槽(双槽分支:entitlements 查 + offers 推荐)
|
|
331
|
+
const offersService = await resolvePromotionOffersPort(reqCtx, ctx);
|
|
332
|
+
const offers = await offersService.searchOffers(q, { offerTypes: ['COUPON'], limit: 50 });
|
|
333
|
+
const items = offers.items.filter((item) => item.availability.status === 'AVAILABLE');
|
|
334
|
+
if (items.length > 0) {
|
|
335
|
+
return {
|
|
336
|
+
cards: [{ cardType: 'promotion-offer-list', data: {
|
|
337
|
+
presentation: 'coupon', mode: 'claimable', items: items.map(offerSummary),
|
|
338
|
+
} }],
|
|
339
|
+
text: `你的券包里还没有优惠券,为你找到 ${items.length} 条可领优惠`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { cards: [], text: '你的券包里还没有优惠券,当前也没有可领取优惠' };
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
cards: [
|
|
346
|
+
{
|
|
347
|
+
cardType: 'promotion-entitlement-list',
|
|
348
|
+
data: {
|
|
349
|
+
mallId: q.scope.mallId,
|
|
350
|
+
miniAppId: q.scope.miniAppId,
|
|
351
|
+
items: page.items.map(entitlementSummary),
|
|
352
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
text: `你的券包里有 ${page.items.length} 张优惠券`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
case 'list-activities': {
|
|
360
|
+
const delegated = await delegateLinkedActivity(ctx, payload, reqCtx);
|
|
361
|
+
if (delegated) return delegated;
|
|
362
|
+
const service = await resolvePromotionActivitiesPort(reqCtx, ctx);
|
|
363
|
+
const page = await service.listActivities(q, { dataScope: 'CURRENT', limit: 10 });
|
|
364
|
+
const { names: categoryNames } = await activityCategoryNames(service);
|
|
365
|
+
return {
|
|
366
|
+
cards: [
|
|
367
|
+
{
|
|
368
|
+
cardType: 'promotion-activity-list',
|
|
369
|
+
data: {
|
|
370
|
+
items: page.items.map((item) => activitySummary(item, categoryNames)),
|
|
371
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
372
|
+
...(page.items.length === 0 ? { noResultsReason: '当前商场近期暂无营销活动' } : {}),
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
text: page.items.length > 0 ? `近期有 ${page.items.length} 场活动` : '近期暂无营销活动',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
default:
|
|
380
|
+
return { cards: [], text: '营销服务已就绪,可问我优惠或活动' };
|
|
381
|
+
}
|
|
20
382
|
},
|
|
21
383
|
};
|
|
22
384
|
}
|