@tbox.cn/app-module-promotion 0.1.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/chunk-AD6OLHSM.js +86 -0
- package/dist/chunk-LQGF6337.js +32 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/server/index.d.ts +62 -0
- package/dist/server/index.js +10 -0
- package/package.json +60 -0
- package/src/client/cards/coupon/index.tsx +12 -0
- package/src/client/index.ts +16 -0
- package/src/index.ts +6 -0
- package/src/server/cards/coupon/meta.ts +23 -0
- package/src/server/handler.ts +22 -0
- package/src/server/index.ts +39 -0
- package/src/server/service.ts +29 -0
- package/tbox.component.json +20 -0
- package/tests/service.test.ts +31 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tbox.cn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// src/server/service.ts
|
|
2
|
+
var InMemoryPromotionService = class {
|
|
3
|
+
constructor(getMember) {
|
|
4
|
+
this.getMember = getMember;
|
|
5
|
+
}
|
|
6
|
+
getMember;
|
|
7
|
+
async queryEligibleCoupons(userId) {
|
|
8
|
+
const member = this.getMember();
|
|
9
|
+
const points = member ? await member.queryPoints(userId) : { level: "silver" };
|
|
10
|
+
const coupons = [
|
|
11
|
+
{
|
|
12
|
+
couponId: `coupon_${userId}_${points.level}`,
|
|
13
|
+
title: points.level === "platinum" ? "\u94C2\u91D1\u4E13\u4EAB\u505C\u8F66\u5238" : points.level === "gold" ? "\u91D1\u5361\u505C\u8F66\u5238" : "\u505C\u8F66\u4F18\u60E0\u5238",
|
|
14
|
+
amount: points.level === "platinum" ? 10 : points.level === "gold" ? 5 : 2,
|
|
15
|
+
status: "AVAILABLE"
|
|
16
|
+
}
|
|
17
|
+
];
|
|
18
|
+
return coupons;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// src/server/cards/coupon/meta.ts
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
var dataSchema = z.object({
|
|
25
|
+
couponId: z.string(),
|
|
26
|
+
title: z.string(),
|
|
27
|
+
amount: z.number(),
|
|
28
|
+
status: z.enum(["AVAILABLE", "USED", "EXPIRED"])
|
|
29
|
+
});
|
|
30
|
+
var meta = {
|
|
31
|
+
cardType: "coupon",
|
|
32
|
+
dataSchema,
|
|
33
|
+
allowedTools: [],
|
|
34
|
+
displayName: "\u4F18\u60E0\u5238\u5361",
|
|
35
|
+
description: "\u5C55\u793A\u63A8\u8350\u53D1\u653E\u7684\u4F18\u60E0\u5238",
|
|
36
|
+
sampleData: { couponId: "coupon_u_1_gold", title: "\u91D1\u5361\u505C\u8F66\u5238", amount: 5, status: "AVAILABLE" },
|
|
37
|
+
schemaVersion: 1
|
|
38
|
+
};
|
|
39
|
+
var meta_default = meta;
|
|
40
|
+
|
|
41
|
+
// src/server/handler.ts
|
|
42
|
+
function createPromotionHandler(ctx) {
|
|
43
|
+
return {
|
|
44
|
+
id: "promotion",
|
|
45
|
+
handle: async (_action, payload) => {
|
|
46
|
+
const promotion = ctx.resolveService("promotion");
|
|
47
|
+
if (!promotion) return { cards: [], text: "\u8425\u9500\u670D\u52A1\u4E0D\u53EF\u7528" };
|
|
48
|
+
const { userId } = payload ?? {};
|
|
49
|
+
if (!userId) return { cards: [], text: "\u7F3A\u5C11 userId" };
|
|
50
|
+
const coupons = await promotion.queryEligibleCoupons(userId);
|
|
51
|
+
if (coupons.length === 0) return { cards: [], text: "\u6682\u65E0\u53EF\u7528\u4F18\u60E0\u5238" };
|
|
52
|
+
return {
|
|
53
|
+
cards: [{ cardType: "coupon", data: coupons[0] }],
|
|
54
|
+
text: `\u63A8\u8350 ${coupons[0].title}\uFF08\u53EF\u62B5 ${coupons[0].amount} \u5143\uFF09`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/server/index.ts
|
|
61
|
+
var currentCtx = null;
|
|
62
|
+
var promotionSingleton = null;
|
|
63
|
+
var cards = {
|
|
64
|
+
coupon: meta_default
|
|
65
|
+
};
|
|
66
|
+
var serverModule = {
|
|
67
|
+
cards,
|
|
68
|
+
register(ctx) {
|
|
69
|
+
currentCtx = ctx;
|
|
70
|
+
if (!promotionSingleton) {
|
|
71
|
+
promotionSingleton = new InMemoryPromotionService(
|
|
72
|
+
() => currentCtx?.resolveService("member")
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const promotion = promotionSingleton;
|
|
76
|
+
ctx.services.register("promotion", promotion);
|
|
77
|
+
ctx.cards.registerMap(cards);
|
|
78
|
+
ctx.handlers.register(createPromotionHandler(ctx));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
InMemoryPromotionService,
|
|
84
|
+
meta_default,
|
|
85
|
+
serverModule
|
|
86
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/client/cards/coupon/index.tsx
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
function CouponCard({ data }) {
|
|
4
|
+
return /* @__PURE__ */ jsxs("div", { className: "mall-card mall-card--coupon", children: [
|
|
5
|
+
/* @__PURE__ */ jsx("div", { className: "mall-card__title", children: data.title }),
|
|
6
|
+
/* @__PURE__ */ jsxs("div", { className: "mall-card__amount", children: [
|
|
7
|
+
"-",
|
|
8
|
+
data.amount,
|
|
9
|
+
" \u5143"
|
|
10
|
+
] }),
|
|
11
|
+
/* @__PURE__ */ jsxs("div", { className: "mall-card__meta", children: [
|
|
12
|
+
data.couponId,
|
|
13
|
+
" \xB7 ",
|
|
14
|
+
data.status
|
|
15
|
+
] })
|
|
16
|
+
] });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/client/index.ts
|
|
20
|
+
var cards = {
|
|
21
|
+
coupon: CouponCard
|
|
22
|
+
};
|
|
23
|
+
var clientModule = {
|
|
24
|
+
cards,
|
|
25
|
+
register(ctx) {
|
|
26
|
+
ctx.cards.registerMap(cards);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
clientModule
|
|
32
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CardComponent } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { ClientContext } from '@tbox.cn/app-sdk/client';
|
|
3
|
+
|
|
4
|
+
/** 模块注册入口:自注册闭环(与服务端 serverModule 对称) */
|
|
5
|
+
declare const clientModule: {
|
|
6
|
+
cards: {
|
|
7
|
+
readonly coupon: CardComponent;
|
|
8
|
+
};
|
|
9
|
+
register(ctx: ClientContext): void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export { clientModule };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { InMemoryPromotionService, couponMeta, serverModule } from './server/index.js';
|
|
2
|
+
export { clientModule } from './client/index.js';
|
|
3
|
+
import '@tbox.cn/app-contracts';
|
|
4
|
+
import 'zod';
|
|
5
|
+
import '@tbox.cn/app-sdk/server';
|
|
6
|
+
import '@tbox.cn/app-contracts-mall';
|
|
7
|
+
import '@tbox.cn/app-sdk/client';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InMemoryPromotionService,
|
|
3
|
+
meta_default,
|
|
4
|
+
serverModule
|
|
5
|
+
} from "./chunk-AD6OLHSM.js";
|
|
6
|
+
import {
|
|
7
|
+
clientModule
|
|
8
|
+
} from "./chunk-LQGF6337.js";
|
|
9
|
+
export {
|
|
10
|
+
InMemoryPromotionService,
|
|
11
|
+
clientModule,
|
|
12
|
+
meta_default as couponMeta,
|
|
13
|
+
serverModule
|
|
14
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as _tbox_cn_app_contracts from '@tbox.cn/app-contracts';
|
|
2
|
+
import { CardMeta } from '@tbox.cn/app-contracts';
|
|
3
|
+
import * as zod from 'zod';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { ServerContext } from '@tbox.cn/app-sdk/server';
|
|
6
|
+
import { PromotionQueryPort, MemberQueryPort, Coupon } from '@tbox.cn/app-contracts-mall';
|
|
7
|
+
|
|
8
|
+
/** PromotionService:营销资格与发券 */
|
|
9
|
+
interface PromotionService extends PromotionQueryPort {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 资格校验 + 推荐降级链(001 安全铁律:模型只读,发券为写入需确认——PoC 只做查询资格)。
|
|
13
|
+
* 通过注入 getMember 访问会员数据,不依赖 member 实现包(结构类型子集接口)。
|
|
14
|
+
*/
|
|
15
|
+
declare class InMemoryPromotionService implements PromotionService {
|
|
16
|
+
private getMember;
|
|
17
|
+
constructor(getMember: () => MemberQueryPort | undefined);
|
|
18
|
+
queryEligibleCoupons(userId: string): Promise<Coupon[]>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare const dataSchema: z.ZodObject<{
|
|
22
|
+
couponId: z.ZodString;
|
|
23
|
+
title: z.ZodString;
|
|
24
|
+
amount: z.ZodNumber;
|
|
25
|
+
status: z.ZodEnum<["AVAILABLE", "USED", "EXPIRED"]>;
|
|
26
|
+
}, "strip", z.ZodTypeAny, {
|
|
27
|
+
status: "AVAILABLE" | "USED" | "EXPIRED";
|
|
28
|
+
couponId: string;
|
|
29
|
+
title: string;
|
|
30
|
+
amount: number;
|
|
31
|
+
}, {
|
|
32
|
+
status: "AVAILABLE" | "USED" | "EXPIRED";
|
|
33
|
+
couponId: string;
|
|
34
|
+
title: string;
|
|
35
|
+
amount: number;
|
|
36
|
+
}>;
|
|
37
|
+
declare const meta: CardMeta<typeof dataSchema>;
|
|
38
|
+
|
|
39
|
+
/** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
|
|
40
|
+
declare const serverModule: {
|
|
41
|
+
cards: {
|
|
42
|
+
readonly coupon: _tbox_cn_app_contracts.CardMeta<zod.ZodObject<{
|
|
43
|
+
couponId: zod.ZodString;
|
|
44
|
+
title: zod.ZodString;
|
|
45
|
+
amount: zod.ZodNumber;
|
|
46
|
+
status: zod.ZodEnum<["AVAILABLE", "USED", "EXPIRED"]>;
|
|
47
|
+
}, "strip", zod.ZodTypeAny, {
|
|
48
|
+
status: "AVAILABLE" | "USED" | "EXPIRED";
|
|
49
|
+
couponId: string;
|
|
50
|
+
title: string;
|
|
51
|
+
amount: number;
|
|
52
|
+
}, {
|
|
53
|
+
status: "AVAILABLE" | "USED" | "EXPIRED";
|
|
54
|
+
couponId: string;
|
|
55
|
+
title: string;
|
|
56
|
+
amount: number;
|
|
57
|
+
}>>;
|
|
58
|
+
};
|
|
59
|
+
register(ctx: ServerContext): void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export { InMemoryPromotionService, meta as couponMeta, serverModule };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tbox.cn/app-module-promotion",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "商圈营销模块:资格校验、推荐降级链、券卡(codegen 源)。",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./server": {
|
|
12
|
+
"types": "./dist/server/index.d.ts",
|
|
13
|
+
"import": "./dist/server/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"types": "./dist/client/index.d.ts",
|
|
17
|
+
"import": "./dist/client/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@tbox.cn/app-sdk": "^0.1.0",
|
|
22
|
+
"@tbox.cn/app-contracts": "^0.1.0",
|
|
23
|
+
"@tbox.cn/app-contracts-mall": "^0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"zod": "^3.24.0",
|
|
27
|
+
"react": "^18.3.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^18.3.12",
|
|
31
|
+
"@types/node": "^24.0.0",
|
|
32
|
+
"typescript": "^5.7.0",
|
|
33
|
+
"tsx": "^4.19.0",
|
|
34
|
+
"vitest": "^4.1.4",
|
|
35
|
+
"zod": "^3.24.0",
|
|
36
|
+
"tsup": "^8.0.0"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"tests",
|
|
42
|
+
"tbox.component.json",
|
|
43
|
+
"tsconfig.json",
|
|
44
|
+
"tsup.config.ts",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE"
|
|
47
|
+
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20.0.0"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"scripts": {
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"build": "tsup"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CouponCardData } from '../../../server/cards/coupon/meta';
|
|
2
|
+
|
|
3
|
+
/** 优惠券卡组件 */
|
|
4
|
+
export function CouponCard({ data }: { data: CouponCardData; cardId: string; isHistory: boolean }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="mall-card mall-card--coupon">
|
|
7
|
+
<div className="mall-card__title">{data.title}</div>
|
|
8
|
+
<div className="mall-card__amount">-{data.amount} 元</div>
|
|
9
|
+
<div className="mall-card__meta">{data.couponId} · {data.status}</div>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CardComponent } from '@tbox.cn/app-contracts';
|
|
2
|
+
import type { ClientContext, ClientModule } from '@tbox.cn/app-sdk/client';
|
|
3
|
+
import { CouponCard } from './cards/coupon';
|
|
4
|
+
|
|
5
|
+
/** 客户端卡片组件 map(key 约定 = cardType;CLI sync 装配读取) */
|
|
6
|
+
const cards = {
|
|
7
|
+
coupon: CouponCard as CardComponent,
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
/** 模块注册入口:自注册闭环(与服务端 serverModule 对称) */
|
|
11
|
+
export const clientModule = {
|
|
12
|
+
cards,
|
|
13
|
+
register(ctx: ClientContext): void {
|
|
14
|
+
ctx.cards.registerMap(cards);
|
|
15
|
+
},
|
|
16
|
+
} satisfies ClientModule;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
3
|
+
|
|
4
|
+
export const dataSchema = z.object({
|
|
5
|
+
couponId: z.string(),
|
|
6
|
+
title: z.string(),
|
|
7
|
+
amount: z.number(),
|
|
8
|
+
status: z.enum(['AVAILABLE', 'USED', 'EXPIRED']),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type CouponCardData = z.infer<typeof dataSchema>;
|
|
12
|
+
|
|
13
|
+
const meta: CardMeta<typeof dataSchema> = {
|
|
14
|
+
cardType: 'coupon',
|
|
15
|
+
dataSchema,
|
|
16
|
+
allowedTools: [],
|
|
17
|
+
displayName: '优惠券卡',
|
|
18
|
+
description: '展示推荐发放的优惠券',
|
|
19
|
+
sampleData: { couponId: 'coupon_u_1_gold', title: '金卡停车券', amount: 5, status: 'AVAILABLE' },
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HandlerDefinition } from '@tbox.cn/app-sdk/server';
|
|
2
|
+
import type { PromotionQueryPort } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
import type { ServerContext } from '@tbox.cn/app-sdk/server';
|
|
4
|
+
|
|
5
|
+
/** promotion handler:查询可领券 → 出 coupon 卡 */
|
|
6
|
+
export function createPromotionHandler(ctx: ServerContext): HandlerDefinition {
|
|
7
|
+
return {
|
|
8
|
+
id: 'promotion',
|
|
9
|
+
handle: async (_action, payload) => {
|
|
10
|
+
const promotion = ctx.resolveService<PromotionQueryPort>('promotion');
|
|
11
|
+
if (!promotion) return { cards: [], text: '营销服务不可用' };
|
|
12
|
+
const { userId } = (payload ?? {}) as { userId?: string };
|
|
13
|
+
if (!userId) return { cards: [], text: '缺少 userId' };
|
|
14
|
+
const coupons = await promotion.queryEligibleCoupons(userId);
|
|
15
|
+
if (coupons.length === 0) return { cards: [], text: '暂无可用优惠券' };
|
|
16
|
+
return {
|
|
17
|
+
cards: [{ cardType: 'coupon', data: coupons[0] as unknown as Record<string, unknown> }],
|
|
18
|
+
text: `推荐 ${coupons[0].title}(可抵 ${coupons[0].amount} 元)`,
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ServerContext, ServerModule } from '@tbox.cn/app-sdk/server';
|
|
2
|
+
import type { MemberQueryPort } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
import { InMemoryPromotionService } from './service';
|
|
4
|
+
import couponMeta from './cards/coupon/meta';
|
|
5
|
+
import { createPromotionHandler } from './handler';
|
|
6
|
+
|
|
7
|
+
export { InMemoryPromotionService } from './service';
|
|
8
|
+
export { default as couponMeta } from './cards/coupon/meta';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 模块级单例(B14):实例在首次 register 时创建,重复调用复用同一实例(状态共享)。
|
|
12
|
+
* member 查询经可变 currentCtx 引用解析到"最近注册的 ctx"——运行期(handle/事件回调)调用
|
|
13
|
+
* resolveService(注册期纯净),测试多 ctx 场景也正确。
|
|
14
|
+
*/
|
|
15
|
+
let currentCtx: ServerContext | null = null;
|
|
16
|
+
let promotionSingleton: InMemoryPromotionService | null = null;
|
|
17
|
+
|
|
18
|
+
/** 卡片 meta 声明 map(key 约定 = meta.cardType;数据 + 类型源) */
|
|
19
|
+
const cards = {
|
|
20
|
+
coupon: couponMeta,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
/** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
|
|
24
|
+
export const serverModule = {
|
|
25
|
+
cards,
|
|
26
|
+
register(ctx: ServerContext): void {
|
|
27
|
+
currentCtx = ctx;
|
|
28
|
+
if (!promotionSingleton) {
|
|
29
|
+
promotionSingleton = new InMemoryPromotionService(() =>
|
|
30
|
+
currentCtx?.resolveService<MemberQueryPort>('member'),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const promotion = promotionSingleton;
|
|
34
|
+
|
|
35
|
+
ctx.services.register('promotion', promotion);
|
|
36
|
+
ctx.cards.registerMap(cards);
|
|
37
|
+
ctx.handlers.register(createPromotionHandler(ctx));
|
|
38
|
+
},
|
|
39
|
+
} satisfies ServerModule;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Coupon } from '@tbox.cn/app-contracts-mall';
|
|
2
|
+
import type { MemberQueryPort, PromotionQueryPort } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
/** PromotionService:营销资格与发券 */
|
|
5
|
+
export interface PromotionService extends PromotionQueryPort {}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 资格校验 + 推荐降级链(001 安全铁律:模型只读,发券为写入需确认——PoC 只做查询资格)。
|
|
9
|
+
* 通过注入 getMember 访问会员数据,不依赖 member 实现包(结构类型子集接口)。
|
|
10
|
+
*/
|
|
11
|
+
export class InMemoryPromotionService implements PromotionService {
|
|
12
|
+
constructor(private getMember: () => MemberQueryPort | undefined) {}
|
|
13
|
+
|
|
14
|
+
async queryEligibleCoupons(userId: string): Promise<Coupon[]> {
|
|
15
|
+
const member = this.getMember();
|
|
16
|
+
const points = member ? await member.queryPoints(userId) : { level: 'silver' as const };
|
|
17
|
+
|
|
18
|
+
// 推荐降级链:按会员等级降级推荐券面额
|
|
19
|
+
const coupons: Coupon[] = [
|
|
20
|
+
{
|
|
21
|
+
couponId: `coupon_${userId}_${points.level}`,
|
|
22
|
+
title: points.level === 'platinum' ? '铂金专享停车券' : points.level === 'gold' ? '金卡停车券' : '停车优惠券',
|
|
23
|
+
amount: points.level === 'platinum' ? 10 : points.level === 'gold' ? 5 : 2,
|
|
24
|
+
status: 'AVAILABLE',
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
return coupons;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "module-promotion",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"kind": "business",
|
|
6
|
+
"risk": { "level": "low", "writeBoundary": "source" },
|
|
7
|
+
"distribution": { "defaultMode": "codegen" },
|
|
8
|
+
"contributes": {
|
|
9
|
+
"handlers": ["promotion"],
|
|
10
|
+
"tools": [],
|
|
11
|
+
"cards": [{ "cardType": "coupon", "schemaVersion": 1 }],
|
|
12
|
+
"routes": [],
|
|
13
|
+
"pages": [],
|
|
14
|
+
"tabs": []
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"modules": [{ "id": "contracts-mall", "required": true }]
|
|
18
|
+
},
|
|
19
|
+
"env": []
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createServerContext } from '@tbox.cn/app-sdk/server';
|
|
3
|
+
import { serverModule } from '../src/server/index';
|
|
4
|
+
|
|
5
|
+
describe('module-promotion 资格校验与降级链', () => {
|
|
6
|
+
it('serverModule.register 注册 card/service/handler', () => {
|
|
7
|
+
const ctx = createServerContext();
|
|
8
|
+
serverModule.register(ctx);
|
|
9
|
+
expect(ctx.cards.has('coupon')).toBe(true);
|
|
10
|
+
expect(ctx.handlers.has('promotion')).toBe(true);
|
|
11
|
+
expect(ctx.resolveService('promotion')).toBeDefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('降级链:silver → 2 元券 / gold → 5 元券 / platinum → 10 元券', async () => {
|
|
15
|
+
const ctx = createServerContext();
|
|
16
|
+
// stub member 供 promotion 查询等级
|
|
17
|
+
ctx.services.register('member', {
|
|
18
|
+
queryPoints: async (userId: string) => {
|
|
19
|
+
const level = userId === 'u_gold' ? 'gold' : userId === 'u_platinum' ? 'platinum' : 'silver';
|
|
20
|
+
return { userId, balance: 100, level: level as 'silver' | 'gold' | 'platinum' };
|
|
21
|
+
},
|
|
22
|
+
queryDiscount: async () => undefined,
|
|
23
|
+
});
|
|
24
|
+
serverModule.register(ctx);
|
|
25
|
+
const promotion = ctx.resolveService<{ queryEligibleCoupons(u: string): Promise<{ amount: number }[]> }>('promotion')!;
|
|
26
|
+
|
|
27
|
+
expect((await promotion.queryEligibleCoupons('u_silver'))[0].amount).toBe(2);
|
|
28
|
+
expect((await promotion.queryEligibleCoupons('u_gold'))[0].amount).toBe(5);
|
|
29
|
+
expect((await promotion.queryEligibleCoupons('u_platinum'))[0].amount).toBe(10);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: {
|
|
5
|
+
index: 'src/index.ts',
|
|
6
|
+
'server/index': 'src/server/index.ts',
|
|
7
|
+
'client/index': 'src/client/index.ts',
|
|
8
|
+
},
|
|
9
|
+
format: ['esm'],
|
|
10
|
+
target: 'node20',
|
|
11
|
+
platform: 'node',
|
|
12
|
+
outDir: 'dist',
|
|
13
|
+
clean: true,
|
|
14
|
+
sourcemap: false,
|
|
15
|
+
dts: true,
|
|
16
|
+
external: [
|
|
17
|
+
'@tbox.cn/app-sdk',
|
|
18
|
+
'@tbox.cn/app-contracts',
|
|
19
|
+
'@tbox.cn/app-contracts-mall',
|
|
20
|
+
'react',
|
|
21
|
+
'react-dom',
|
|
22
|
+
'zod',
|
|
23
|
+
'@mastra/core',
|
|
24
|
+
'express',
|
|
25
|
+
],
|
|
26
|
+
esbuildOptions(options) {
|
|
27
|
+
options.jsx = 'automatic';
|
|
28
|
+
},
|
|
29
|
+
});
|