@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
package/src/server/index.ts
CHANGED
|
@@ -1,39 +1,134 @@
|
|
|
1
|
-
import type { ServerContext, ServerModule } from '@tbox.cn/app-sdk/server';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
1
|
+
import type { ServerContext, ServerModule, PreToolDef } from '@tbox.cn/app-sdk/server';
|
|
2
|
+
import { definePreTool, getRequestContext, loadModuleSkills } from '@tbox.cn/app-sdk/server';
|
|
3
|
+
import { Router } from 'express';
|
|
4
|
+
import type { Request, Response } from 'express';
|
|
5
|
+
import { toMallQueryContext, withMallScopeFromQuery } from '@tbox.cn/app-contracts-mall';
|
|
6
|
+
import { inMemoryPromotion, resolvePromotionEntitlementsPort } from './ports';
|
|
7
|
+
import promotionOfferListMeta from './cards/promotion-offer-list/meta';
|
|
8
|
+
import promotionOfferDetailMeta from './cards/promotion-offer-detail/meta';
|
|
9
|
+
import promotionEntitlementListMeta from './cards/promotion-entitlement-list/meta';
|
|
10
|
+
import promotionActivityListMeta from './cards/promotion-activity-list/meta';
|
|
11
|
+
import promotionActivityDetailMeta from './cards/promotion-activity-detail/meta';
|
|
12
|
+
import promotionActivityCategoryListMeta from './cards/promotion-activity-category-list/meta';
|
|
13
|
+
import promotionBestDealMeta from './cards/promotion-best-deal/meta';
|
|
14
|
+
import { createPromotionTools, entitlementSummary } from './tools';
|
|
15
|
+
import { createPromotionActions } from './actions';
|
|
16
|
+
import { createPromotionIntentHandler } from './handler';
|
|
6
17
|
|
|
7
18
|
export { InMemoryPromotionService } from './service';
|
|
8
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
resolvePromotionOffersPort,
|
|
21
|
+
resolvePromotionEntitlementsPort,
|
|
22
|
+
resolvePromotionActivitiesPort,
|
|
23
|
+
resolvePromotionAcquisitionPort,
|
|
24
|
+
inMemoryPromotion,
|
|
25
|
+
} from './ports';
|
|
26
|
+
export { promotionCards } from './cards';
|
|
27
|
+
|
|
28
|
+
/** 模块级 ServerContext 引用(getCtx 先例) */
|
|
29
|
+
let serverCtx: ServerContext | undefined;
|
|
30
|
+
const getCtx = (): ServerContext => {
|
|
31
|
+
if (!serverCtx) throw new Error('promotion 模块未注册(ServerContext 未绑定)');
|
|
32
|
+
return serverCtx;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const tools = createPromotionTools({ getCtx });
|
|
9
36
|
|
|
10
37
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
38
|
+
* 可领券问句确定性预执行(W5 P1,2026-09-01):券意图轮零 LLM 预执行出券列表卡。
|
|
39
|
+
* - run = 零参 CLAIMABLE(可领券查询语义,与「有什么券」问词匹配);
|
|
40
|
+
* - OWNED 意图排除:用户说「我的券/已领取/券包」需要 mode=OWNED,交模型精确传参;
|
|
41
|
+
* - silent:不发前端事件;结果 reminder 注入 + cards 经 capture 入本轮候选池;
|
|
42
|
+
* - 出参体积门:CLAIMABLE 出参 build 期实测 ≤ 2KB(promotion pretool 测试断言),超限即撤本 preTool。
|
|
14
43
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
44
|
+
const OWNED_INTENT_RE = /我的券|已领|券包|券码/;
|
|
45
|
+
|
|
46
|
+
const listCouponsPreTool = definePreTool(tools.listCoupons, {
|
|
47
|
+
routing: { mode: 'keywords', terms: ['优惠券', '领券', '有什么券', '可领'] },
|
|
48
|
+
decide: ({ text }) => {
|
|
49
|
+
if (!text?.trim()) return { decision: 'skip', reason: '空文本' };
|
|
50
|
+
if (OWNED_INTENT_RE.test(text)) return { decision: 'skip', reason: 'OWNED 意图(模型传 mode)' };
|
|
51
|
+
return { decision: 'run', args: { mode: 'CLAIMABLE' as const } };
|
|
52
|
+
},
|
|
53
|
+
silent: true,
|
|
54
|
+
});
|
|
17
55
|
|
|
18
|
-
|
|
56
|
+
const preTools = {
|
|
57
|
+
listCoupons: listCouponsPreTool,
|
|
58
|
+
} satisfies Record<string, PreToolDef>;
|
|
59
|
+
|
|
60
|
+
/** 卡片 meta 声明 map(key 约定 = meta.cardType;字面量形态——doctor AST 解析依赖) */
|
|
19
61
|
const cards = {
|
|
20
|
-
|
|
62
|
+
'promotion-offer-list': promotionOfferListMeta,
|
|
63
|
+
'promotion-offer-detail': promotionOfferDetailMeta,
|
|
64
|
+
'promotion-entitlement-list': promotionEntitlementListMeta,
|
|
65
|
+
'promotion-activity-list': promotionActivityListMeta,
|
|
66
|
+
'promotion-activity-detail': promotionActivityDetailMeta,
|
|
67
|
+
'promotion-activity-category-list': promotionActivityCategoryListMeta,
|
|
68
|
+
'promotion-best-deal': promotionBestDealMeta,
|
|
21
69
|
} as const;
|
|
22
70
|
|
|
71
|
+
/**
|
|
72
|
+
* 文件加载式 skill(009 两形态之二;模板 module-weather 同款):
|
|
73
|
+
* 顶层 await loadModuleSkills → 填回声明式通道(装配端 registerAll 自动注册)。
|
|
74
|
+
* codegen 展开改写约定:第一参随包名改写(@tbox.cn/app-module-promotion → @app/module-promotion)。
|
|
75
|
+
*/
|
|
76
|
+
const skills = Object.fromEntries(
|
|
77
|
+
(await loadModuleSkills('@tbox.cn/app-module-promotion', import.meta.url)).map((s) => [s.name, s]),
|
|
78
|
+
);
|
|
79
|
+
|
|
23
80
|
/** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
|
|
24
81
|
export const serverModule = {
|
|
82
|
+
declaration: {
|
|
83
|
+
moduleId: 'promotion',
|
|
84
|
+
},
|
|
25
85
|
cards,
|
|
86
|
+
skills,
|
|
87
|
+
preTools,
|
|
26
88
|
register(ctx: ServerContext): void {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
);
|
|
89
|
+
serverCtx = ctx;
|
|
90
|
+
ctx.services.register('promotion', inMemoryPromotion);
|
|
91
|
+
for (const tool of Object.values(tools)) {
|
|
92
|
+
ctx.tools.register(tool);
|
|
32
93
|
}
|
|
33
|
-
const promotion = promotionSingleton;
|
|
34
|
-
|
|
35
|
-
ctx.services.register('promotion', promotion);
|
|
36
94
|
ctx.cards.registerMap(cards);
|
|
37
|
-
ctx.handlers.register(
|
|
95
|
+
ctx.handlers.register(createPromotionIntentHandler(ctx));
|
|
96
|
+
|
|
97
|
+
// 券码详情弹窗按需调用独立 provider 能力;列表接口只负责券包索引。
|
|
98
|
+
const router = Router();
|
|
99
|
+
router.get('/entitlement-detail', async (req: Request, res: Response) => {
|
|
100
|
+
const entitlementRef = typeof req.query['entitlementRef'] === 'string' ? req.query['entitlementRef'].trim() : '';
|
|
101
|
+
if (!entitlementRef) {
|
|
102
|
+
res.status(400).json({ error: 'entitlementRef required' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const reqCtx = getRequestContext(req);
|
|
106
|
+
if (!reqCtx) {
|
|
107
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const scopedReqCtx = withMallScopeFromQuery(reqCtx, req.query);
|
|
112
|
+
const service = await resolvePromotionEntitlementsPort(scopedReqCtx, ctx);
|
|
113
|
+
const detail = await service.getEntitlementDetail({
|
|
114
|
+
...toMallQueryContext(scopedReqCtx),
|
|
115
|
+
}, entitlementRef);
|
|
116
|
+
if (!detail) {
|
|
117
|
+
res.status(404).json({ error: 'not_found' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
res.json({ data: entitlementSummary(detail) });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
ctx.logger?.warn(`promotion entitlement-detail route failed: ${(error as Error).message}`);
|
|
123
|
+
res.status(500).json({ error: 'internal_error' });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
ctx.routes.register('promotion', router);
|
|
127
|
+
|
|
128
|
+
// 015 S6:唯一写动作 acquire(register 闭包 ctx——SubjectBinding resolveService + 幂等)
|
|
129
|
+
const actions = createPromotionActions(ctx);
|
|
130
|
+
for (const action of Object.values(actions)) {
|
|
131
|
+
ctx.cardActions.register(action);
|
|
132
|
+
}
|
|
38
133
|
},
|
|
39
134
|
} satisfies ServerModule;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type PromotionNavigationIntent =
|
|
2
|
+
| { kind: 'activity-detail'; title: string }
|
|
3
|
+
| { kind: 'offer-detail'; title: string }
|
|
4
|
+
| { kind: 'entitlement-detail'; title: string }
|
|
5
|
+
| { kind: 'activity-category'; name: string };
|
|
6
|
+
|
|
7
|
+
export const promotionNavigationQuery = {
|
|
8
|
+
activityDetail: (title: string) => `查看「${title}」的活动详情`,
|
|
9
|
+
offerDetail: (title: string) => `查看「${title}」的优惠详情`,
|
|
10
|
+
entitlementDetail: (title: string) => `查看「${title}」这张优惠券的使用详情`,
|
|
11
|
+
activityCategory: (name: string) => `查看「${name}」分类的活动`,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/** 只解析模块自身生成的自然语言导航句式,避免宽泛关键词误截普通用户问题。 */
|
|
15
|
+
export function parsePromotionNavigationQuery(text: string): PromotionNavigationIntent | null {
|
|
16
|
+
const subject = text.match(/「([^」]{1,200})」/)?.[1]?.trim();
|
|
17
|
+
if (!subject) return null;
|
|
18
|
+
if (text === promotionNavigationQuery.activityDetail(subject)) return { kind: 'activity-detail', title: subject };
|
|
19
|
+
if (text === promotionNavigationQuery.offerDetail(subject)) return { kind: 'offer-detail', title: subject };
|
|
20
|
+
if (text === promotionNavigationQuery.entitlementDetail(subject)) return { kind: 'entitlement-detail', title: subject };
|
|
21
|
+
if (text === promotionNavigationQuery.activityCategory(subject)) return { kind: 'activity-category', name: subject };
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promotion Port 分槽解析(017 槽位原子化):四槽独立解析(offers/entitlements/
|
|
3
|
+
* activities/acquisition)。同域四槽共享厂商网关 transport(request/requestCrm 双信封),
|
|
4
|
+
* provider manifest 一槽一条目、共享 implementation id(catalog 聚合后同 impl 供给)。
|
|
5
|
+
*
|
|
6
|
+
* 预期缺席(未配置/实例缺键/禁用)→ InMemoryPromotionService 兜底;其余错误原样上抛。
|
|
7
|
+
* 部分绑定形态(offers 厂商 + acquisition InMemory)为显式行为:acquire 走 InMemory
|
|
8
|
+
* 幂等纯函数派生,与厂商侧领取状态互不可见。
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
mallScopeOf,
|
|
12
|
+
PROMOTION_ACQUISITION_SERVICE,
|
|
13
|
+
PROMOTION_ACTIVITIES_SERVICE,
|
|
14
|
+
PROMOTION_ENTITLEMENTS_SERVICE,
|
|
15
|
+
PROMOTION_OFFERS_SERVICE,
|
|
16
|
+
type PromotionAcquisitionPort,
|
|
17
|
+
type PromotionActivityPort,
|
|
18
|
+
type PromotionEntitlementPort,
|
|
19
|
+
type PromotionOfferQueryPort,
|
|
20
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
21
|
+
import { isExpectedProviderAbsence, type RequestContext } from '@tbox.cn/app-contracts';
|
|
22
|
+
import type { ServerContext } from '@tbox.cn/app-sdk/server';
|
|
23
|
+
import { InMemoryPromotionService } from './service';
|
|
24
|
+
|
|
25
|
+
/** 缺省 InMemory 单例(模块包顶层,B14;跨模块消费方 resolveService('promotion') 兼容) */
|
|
26
|
+
export const inMemoryPromotion = new InMemoryPromotionService();
|
|
27
|
+
|
|
28
|
+
async function resolvePromotionSlot<T>(
|
|
29
|
+
service: string,
|
|
30
|
+
reqCtx: RequestContext,
|
|
31
|
+
ctx: ServerContext | undefined,
|
|
32
|
+
fallback: T,
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
if (!ctx?.integrations) return fallback;
|
|
35
|
+
try {
|
|
36
|
+
return (await ctx.integrations.resolveService({
|
|
37
|
+
service,
|
|
38
|
+
scope: mallScopeOf(reqCtx),
|
|
39
|
+
request: reqCtx,
|
|
40
|
+
})) as T;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (isExpectedProviderAbsence(e)) return fallback; // 预期缺席 → InMemory 兜底
|
|
43
|
+
throw e; // PROVIDER_NOT_IMPLEMENTED 等非预期缺席 → 上抛(配置即声明,F5 起无回退)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const resolvePromotionOffersPort = (reqCtx: RequestContext, ctx?: ServerContext) =>
|
|
48
|
+
resolvePromotionSlot<PromotionOfferQueryPort>(PROMOTION_OFFERS_SERVICE, reqCtx, ctx, inMemoryPromotion);
|
|
49
|
+
export const resolvePromotionEntitlementsPort = (reqCtx: RequestContext, ctx?: ServerContext) =>
|
|
50
|
+
resolvePromotionSlot<PromotionEntitlementPort>(PROMOTION_ENTITLEMENTS_SERVICE, reqCtx, ctx, inMemoryPromotion);
|
|
51
|
+
export const resolvePromotionActivitiesPort = (reqCtx: RequestContext, ctx?: ServerContext) =>
|
|
52
|
+
resolvePromotionSlot<PromotionActivityPort>(PROMOTION_ACTIVITIES_SERVICE, reqCtx, ctx, inMemoryPromotion);
|
|
53
|
+
export const resolvePromotionAcquisitionPort = (reqCtx: RequestContext, ctx?: ServerContext) =>
|
|
54
|
+
resolvePromotionSlot<PromotionAcquisitionPort>(PROMOTION_ACQUISITION_SERVICE, reqCtx, ctx, inMemoryPromotion);
|
|
55
|
+
|
|
56
|
+
export { InMemoryPromotionService };
|
package/src/server/service.ts
CHANGED
|
@@ -1,29 +1,248 @@
|
|
|
1
|
-
import type { Coupon } from '@tbox.cn/app-contracts-mall';
|
|
2
|
-
import type { MemberBenefitsPort, PromotionQueryPort } from '@tbox.cn/app-contracts-mall';
|
|
3
|
-
|
|
4
|
-
/** PromotionService:营销资格与发券 */
|
|
5
|
-
export interface PromotionService extends PromotionQueryPort {}
|
|
6
|
-
|
|
7
1
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* InMemory 营销服务(015 S6:四 Port 演示实现;未装配 provider 降级 + 单测基座)。
|
|
3
|
+
* 演示数据:2 Offer(满减券/团购)+ 1 活动 + entitlements 按 acquire 落账。
|
|
10
4
|
*/
|
|
5
|
+
import type {
|
|
6
|
+
PromotionQueryContext,
|
|
7
|
+
PromotionSearchInput,
|
|
8
|
+
PromotionOfferQueryPort,
|
|
9
|
+
PromotionEntitlementPort,
|
|
10
|
+
PromotionActivityPort,
|
|
11
|
+
PromotionAcquisitionPort,
|
|
12
|
+
PromotionOffer,
|
|
13
|
+
PromotionOfferPage,
|
|
14
|
+
PromotionEntitlement,
|
|
15
|
+
PromotionEntitlementPage,
|
|
16
|
+
PromotionActivity,
|
|
17
|
+
PromotionActivityPage,
|
|
18
|
+
PromotionActivityDetail,
|
|
19
|
+
PromotionOperation,
|
|
20
|
+
PromotionBestDeal,
|
|
21
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
22
|
+
|
|
23
|
+
export interface PromotionService
|
|
24
|
+
extends PromotionOfferQueryPort,
|
|
25
|
+
PromotionEntitlementPort,
|
|
26
|
+
PromotionActivityPort,
|
|
27
|
+
PromotionAcquisitionPort {
|
|
28
|
+
/** 是否真正装配写通道;C 端只读 Provider 不得据 acquire 方法误判。 */
|
|
29
|
+
readonly supportsAcquisition?: boolean;
|
|
30
|
+
/** provider 是否允许输出优惠测算卡;未装配厂商能力时不构造旧候选卡。 */
|
|
31
|
+
readonly supportsBestDeal?: boolean;
|
|
32
|
+
/** 推荐结果是否统一使用可领券列表展示,避免进入旧 recommendation 分支。 */
|
|
33
|
+
readonly usesUnifiedCouponList?: boolean;
|
|
34
|
+
/** 活动分类名称 → categoryId 归一(provider 装配态实现;InMemory 缺省无——工具层 ?. 降级) */
|
|
35
|
+
resolveCategoryId?(input: string): Promise<string | undefined>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const now = () => new Date().toISOString();
|
|
39
|
+
const inDays = (d: number) => new Date(Date.now() + d * 86400_000).toISOString();
|
|
40
|
+
|
|
41
|
+
const DEMO_OFFERS: PromotionOffer[] = [
|
|
42
|
+
{
|
|
43
|
+
offerRef: 'demo:coupon:1001',
|
|
44
|
+
offerType: 'COUPON',
|
|
45
|
+
title: '满 100 减 20 元券',
|
|
46
|
+
subtitle: '全场通用(部分特例商品除外)',
|
|
47
|
+
tags: ['通用'],
|
|
48
|
+
validity: { start: now(), end: inDays(30) },
|
|
49
|
+
facets: [{ kind: 'COUPON', discountType: 'AMOUNT', value: 2000, thresholdCents: 10000 }],
|
|
50
|
+
availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: now(), authoritative: false },
|
|
51
|
+
participation: { mode: 'CLAIM_THEN_PURCHASE', steps: [{ title: '领取', description: '点击领取后自动存入卡包' }] },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
offerRef: 'demo:coupon:1002',
|
|
55
|
+
offerType: 'COUPON',
|
|
56
|
+
title: '餐饮 85 折券',
|
|
57
|
+
subtitle: '限餐饮楼层门店',
|
|
58
|
+
tags: ['餐饮'],
|
|
59
|
+
validity: { start: now(), end: inDays(15) },
|
|
60
|
+
facets: [{ kind: 'COUPON', discountType: 'PERCENTAGE', value: 85, thresholdCents: 5000 }],
|
|
61
|
+
availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: now(), authoritative: false },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
offerRef: 'demo-c:groupon:2001',
|
|
65
|
+
offerType: 'GROUPON',
|
|
66
|
+
title: '双人餐团购 129 元',
|
|
67
|
+
tags: ['团购', '餐饮'],
|
|
68
|
+
validity: { end: inDays(20) },
|
|
69
|
+
facets: [{ kind: 'GROUPON', requiredParticipants: 2 }],
|
|
70
|
+
availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: now(), authoritative: true },
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const DEMO_ACTIVITY: PromotionActivityDetail = {
|
|
75
|
+
activityRef: 'demo-act:3001',
|
|
76
|
+
title: '周末亲子市集',
|
|
77
|
+
subtitle: '手作体验 + 亲子游戏',
|
|
78
|
+
categoryId: 'family',
|
|
79
|
+
registrationRequired: true,
|
|
80
|
+
remainingQuota: 12,
|
|
81
|
+
startAt: inDays(3),
|
|
82
|
+
endAt: inDays(4),
|
|
83
|
+
offerRefs: ['demo:coupon:1002'],
|
|
84
|
+
description: '本周末中庭亲子市集,含 10+ 手作摊位',
|
|
85
|
+
sessions: [
|
|
86
|
+
{ sessionRef: 's_1', title: '第一场 10:00-12:00', startAt: inDays(3), remainingQuota: 5, status: 'OPEN' },
|
|
87
|
+
{ sessionRef: 's_2', title: '第二场 14:00-16:00', startAt: inDays(3), remainingQuota: 7, status: 'OPEN' },
|
|
88
|
+
],
|
|
89
|
+
guide: {
|
|
90
|
+
steps: [
|
|
91
|
+
{ title: '领取优惠券', description: '领取餐饮 85 折券,市集餐饮摊位可用' },
|
|
92
|
+
{ title: '选择场次报名', description: '凭报名码入场' },
|
|
93
|
+
],
|
|
94
|
+
conditions: ['限会员本人参与', '每场限量 20 组家庭'],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
11
98
|
export class InMemoryPromotionService implements PromotionService {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
99
|
+
readonly supportsAcquisition = true;
|
|
100
|
+
private entitlements = new Map<string, PromotionEntitlement[]>();
|
|
101
|
+
private operations = new Map<string, PromotionOperation>();
|
|
102
|
+
private acquireKeys = new Map<string, string>(); // idempotencyKey → operationRef
|
|
103
|
+
|
|
104
|
+
async searchOffers(_q: PromotionQueryContext, input: PromotionSearchInput): Promise<PromotionOfferPage> {
|
|
105
|
+
const keyword = input.keyword?.trim();
|
|
106
|
+
const typed = input.offerTypes?.length
|
|
107
|
+
? DEMO_OFFERS.filter((offer) => input.offerTypes!.includes(offer.offerType))
|
|
108
|
+
: DEMO_OFFERS;
|
|
109
|
+
const items = keyword
|
|
110
|
+
? typed.filter((o) => o.title.includes(keyword) || o.tags.some((t) => t.includes(keyword)))
|
|
111
|
+
: typed;
|
|
112
|
+
const limit = Math.min(50, input.limit ?? 10);
|
|
113
|
+
return { items: items.slice(0, limit), hasMore: items.length > limit };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async getOfferDetail(
|
|
117
|
+
_q: PromotionQueryContext,
|
|
118
|
+
input: { offerRef?: string; entitlementRef?: string; keyword?: string },
|
|
119
|
+
): Promise<PromotionOffer | null> {
|
|
120
|
+
if (input.offerRef) return DEMO_OFFERS.find((o) => o.offerRef === input.offerRef) ?? null;
|
|
121
|
+
if (input.entitlementRef) {
|
|
122
|
+
for (const list of this.entitlements.values()) {
|
|
123
|
+
const ent = list.find((e) => e.entitlementRef === input.entitlementRef);
|
|
124
|
+
if (ent?.offerRef) return DEMO_OFFERS.find((o) => o.offerRef === ent.offerRef) ?? null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (input.keyword) {
|
|
129
|
+
const page = await this.searchOffers(_q, { keyword: input.keyword, limit: 1 });
|
|
130
|
+
return page.items[0] ?? null;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async recommendOffers(q: PromotionQueryContext, input: PromotionSearchInput): Promise<readonly PromotionOffer[]> {
|
|
136
|
+
const page = await this.searchOffers(q, { ...input, limit: 3 });
|
|
137
|
+
return page.items;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async calculateBestDeal(
|
|
141
|
+
_q: PromotionQueryContext,
|
|
142
|
+
input: { currentAmountCents: number; candidateOfferRefs?: readonly string[] },
|
|
143
|
+
): Promise<PromotionBestDeal> {
|
|
144
|
+
const candidates = input.candidateOfferRefs
|
|
145
|
+
? DEMO_OFFERS.filter((o) => input.candidateOfferRefs!.includes(o.offerRef))
|
|
146
|
+
: DEMO_OFFERS;
|
|
147
|
+
let best: PromotionBestDeal = {
|
|
148
|
+
offerRef: '',
|
|
149
|
+
title: '暂无适用优惠',
|
|
150
|
+
estimatedSavingCents: 0,
|
|
151
|
+
reason: '当前金额未达到可用门槛',
|
|
152
|
+
candidatesConsidered: candidates.length,
|
|
153
|
+
};
|
|
154
|
+
for (const offer of candidates) {
|
|
155
|
+
const facet = offer.facets.find((f): f is Extract<typeof f, { kind: 'COUPON' }> => f.kind === 'COUPON');
|
|
156
|
+
if (!facet) continue;
|
|
157
|
+
const threshold = facet.thresholdCents ?? 0;
|
|
158
|
+
if (input.currentAmountCents < threshold) continue;
|
|
159
|
+
const saving = facet.discountType === 'AMOUNT' ? facet.value : Math.round((input.currentAmountCents * (100 - facet.value)) / 100);
|
|
160
|
+
if (saving > best.estimatedSavingCents) {
|
|
161
|
+
best = {
|
|
162
|
+
offerRef: offer.offerRef,
|
|
163
|
+
title: offer.title,
|
|
164
|
+
estimatedSavingCents: saving,
|
|
165
|
+
reason: `满 ${(threshold / 100).toFixed(0)} 元可用,预计省 ${(saving / 100).toFixed(2)} 元`,
|
|
166
|
+
candidatesConsidered: candidates.length,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async listEntitlements(q: PromotionQueryContext, _input: PromotionSearchInput): Promise<PromotionEntitlementPage> {
|
|
174
|
+
const items = this.entitlementsOf(q);
|
|
175
|
+
return { items, hasMore: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async getEntitlementDetail(q: PromotionQueryContext, entitlementRef: string): Promise<PromotionEntitlement | null> {
|
|
179
|
+
return this.entitlementsOf(q).find((e) => e.entitlementRef === entitlementRef) ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listActivities(
|
|
183
|
+
_q: PromotionQueryContext,
|
|
184
|
+
input: Parameters<PromotionActivityPort['listActivities']>[1],
|
|
185
|
+
): Promise<PromotionActivityPage> {
|
|
186
|
+
const { sessions: _s, guide: _g, ...activity } = DEMO_ACTIVITY;
|
|
187
|
+
void _s;
|
|
188
|
+
void _g;
|
|
189
|
+
const keyword = input.keyword?.trim();
|
|
190
|
+
const matchesKeyword = !keyword || `${activity.title} ${activity.subtitle ?? ''}`.includes(keyword);
|
|
191
|
+
const matchesRegistration =
|
|
192
|
+
input.registrationRequired === undefined || activity.registrationRequired === input.registrationRequired;
|
|
193
|
+
const matchesCategory = !input.categoryId || activity.categoryId === input.categoryId;
|
|
194
|
+
return {
|
|
195
|
+
items: matchesKeyword && matchesRegistration && matchesCategory ? [activity as PromotionActivity] : [],
|
|
196
|
+
hasMore: false,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async getActivityDetail(_q: PromotionQueryContext, activityRef: string): Promise<PromotionActivityDetail | null> {
|
|
201
|
+
return DEMO_ACTIVITY.activityRef === activityRef ? DEMO_ACTIVITY : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async listActivitySessions(_q: PromotionQueryContext, activityRef: string) {
|
|
205
|
+
return activityRef === DEMO_ACTIVITY.activityRef ? DEMO_ACTIVITY.sessions : [];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async listActivityCategories() {
|
|
209
|
+
return [{ categoryId: 'family', name: '亲子' }] as const;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async acquire(
|
|
213
|
+
q: PromotionQueryContext,
|
|
214
|
+
input: { offerRef: string; externalMemberRef: string; idempotencyKey: string },
|
|
215
|
+
): Promise<PromotionOperation> {
|
|
216
|
+
// 幂等:同键返回首果
|
|
217
|
+
const existingRef = this.acquireKeys.get(input.idempotencyKey);
|
|
218
|
+
if (existingRef) {
|
|
219
|
+
const existing = this.operations.get(existingRef);
|
|
220
|
+
if (existing) return existing;
|
|
221
|
+
}
|
|
222
|
+
const offer = DEMO_OFFERS.find((o) => o.offerRef === input.offerRef);
|
|
223
|
+
const operationRef = `op_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
224
|
+
const operation: PromotionOperation = offer
|
|
225
|
+
? { operationRef, status: 'SUCCEEDED', offerRef: offer.offerRef, codeMasked: 'AB**CD**EF', observedAt: now() }
|
|
226
|
+
: { operationRef, status: 'REJECTED', offerRef: input.offerRef, code: '30017', message: '优惠不存在或不可领取', observedAt: now() };
|
|
227
|
+
if (offer) {
|
|
228
|
+
const list = this.entitlements.get(q.subjectId ?? 'anon') ?? [];
|
|
229
|
+
list.unshift({
|
|
230
|
+
entitlementRef: `ent_${operationRef}`,
|
|
231
|
+
offerRef: offer.offerRef,
|
|
232
|
+
title: offer.title,
|
|
233
|
+
entitlementType: offer.offerType,
|
|
234
|
+
status: 'EFFECTIVE',
|
|
235
|
+
codeMasked: 'AB**CD**EF',
|
|
236
|
+
validUntil: offer.validity.end,
|
|
237
|
+
});
|
|
238
|
+
this.entitlements.set(q.subjectId ?? 'anon', list);
|
|
239
|
+
}
|
|
240
|
+
this.operations.set(operationRef, operation);
|
|
241
|
+
this.acquireKeys.set(input.idempotencyKey, operationRef);
|
|
242
|
+
return operation;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private entitlementsOf(q: PromotionQueryContext): PromotionEntitlement[] {
|
|
246
|
+
return this.entitlements.get(q.subjectId ?? 'anon') ?? [];
|
|
28
247
|
}
|
|
29
248
|
}
|