@tbox.cn/app-module-member 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 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,17 @@
1
+ # @tbox.cn/app-module-member
2
+
3
+ 商圈会员模块:积分查询/扣减/发放/退款、等级(codegen 源)。
4
+
5
+ ## 安装
6
+
7
+ ```sh
8
+ pnpm add @tbox.cn/app-module-member
9
+ ```
10
+
11
+ ## 用法
12
+
13
+ 在生成应用中:
14
+
15
+ ```sh
16
+ tbox-app module add @tbox.cn/app-module-member
17
+ ```
@@ -0,0 +1,160 @@
1
+ // src/server/service.ts
2
+ var InMemoryMemberService = class {
3
+ balances = /* @__PURE__ */ new Map();
4
+ levels = /* @__PURE__ */ new Map();
5
+ idempotentKeys = /* @__PURE__ */ new Set();
6
+ audit = [];
7
+ constructor(initial = {}) {
8
+ for (const [userId, balance] of Object.entries(initial)) {
9
+ this.balances.set(userId, balance);
10
+ }
11
+ }
12
+ async queryPoints(userId) {
13
+ return {
14
+ userId,
15
+ balance: this.balances.get(userId) ?? 0,
16
+ level: this.levelOf(userId)
17
+ };
18
+ }
19
+ async queryDiscount(userId) {
20
+ const level = this.levelOf(userId);
21
+ const map = {
22
+ silver: { level: "silver", discount: 0.95 },
23
+ gold: { level: "gold", discount: 0.9 },
24
+ platinum: { level: "platinum", discount: 0.85 }
25
+ };
26
+ return map[level];
27
+ }
28
+ async deductPoints(userId, amount, idempotencyKey) {
29
+ if (this.idempotentKeys.has(idempotencyKey)) return true;
30
+ const current = this.balances.get(userId) ?? 0;
31
+ if (amount <= 0 || current < amount) return false;
32
+ this.balances.set(userId, current - amount);
33
+ this.idempotentKeys.add(idempotencyKey);
34
+ this.audit.push({ action: "deduct", userId, amount, key: idempotencyKey });
35
+ this.maybeUpgrade(userId);
36
+ return true;
37
+ }
38
+ async refundPoints(userId, amount, idempotencyKey) {
39
+ if (this.idempotentKeys.has(`refund:${idempotencyKey}`)) return true;
40
+ const current = this.balances.get(userId) ?? 0;
41
+ this.balances.set(userId, current + amount);
42
+ this.idempotentKeys.add(`refund:${idempotencyKey}`);
43
+ this.audit.push({ action: "refund", userId, amount, key: idempotencyKey });
44
+ return true;
45
+ }
46
+ async awardPoints(userId, amount) {
47
+ const current = this.balances.get(userId) ?? 0;
48
+ this.balances.set(userId, current + amount);
49
+ this.audit.push({ action: "award", userId, amount });
50
+ this.maybeUpgrade(userId);
51
+ return true;
52
+ }
53
+ levelOf(userId) {
54
+ const cached = this.levels.get(userId);
55
+ if (cached) return cached;
56
+ const balance = this.balances.get(userId) ?? 0;
57
+ if (balance >= 1e3) return "platinum";
58
+ if (balance >= 300) return "gold";
59
+ return "silver";
60
+ }
61
+ maybeUpgrade(userId) {
62
+ const newLevel = this.levelOf(userId);
63
+ const prev = this.levels.get(userId);
64
+ this.levels.set(userId, newLevel);
65
+ if (prev && prev !== newLevel) {
66
+ this.levelChangeListeners.forEach((fn) => fn({ userId, newLevel }));
67
+ }
68
+ }
69
+ levelChangeListeners = /* @__PURE__ */ new Set();
70
+ onLevelChange(fn) {
71
+ this.levelChangeListeners.add(fn);
72
+ }
73
+ };
74
+
75
+ // src/server/cards/points/meta.ts
76
+ import { z } from "zod";
77
+ var dataSchema = z.object({
78
+ userId: z.string(),
79
+ balance: z.number(),
80
+ level: z.enum(["silver", "gold", "platinum"])
81
+ });
82
+ var meta = {
83
+ cardType: "points",
84
+ dataSchema,
85
+ allowedTools: ["queryPoints"],
86
+ displayName: "\u4F1A\u5458\u79EF\u5206\u5361",
87
+ description: "\u5C55\u793A\u4F1A\u5458\u5F53\u524D\u79EF\u5206\u4F59\u989D\u4E0E\u7B49\u7EA7",
88
+ sampleData: { userId: "u_1001", balance: 860, level: "gold" },
89
+ schemaVersion: 1
90
+ };
91
+ var meta_default = meta;
92
+
93
+ // src/server/tool.ts
94
+ import { createTool } from "@tbox.cn/app-sdk/server";
95
+ import { z as z2 } from "zod";
96
+ function createQueryPointsTool(getMember) {
97
+ return createTool({
98
+ id: "queryPoints",
99
+ description: "\u67E5\u8BE2\u4F1A\u5458\u5F53\u524D\u79EF\u5206\u4F59\u989D\u4E0E\u7B49\u7EA7",
100
+ inputSchema: z2.object({
101
+ userId: z2.string().describe("\u4F1A\u5458 userId")
102
+ }),
103
+ outputSchema: z2.object({
104
+ userId: z2.string(),
105
+ balance: z2.number(),
106
+ level: z2.enum(["silver", "gold", "platinum"])
107
+ }),
108
+ execute: async ({ userId }) => {
109
+ const member2 = getMember();
110
+ if (!member2) return { userId, balance: 0, level: "silver" };
111
+ return member2.queryPoints(userId);
112
+ }
113
+ });
114
+ }
115
+
116
+ // src/server/handler.ts
117
+ function createMemberHandler(ctx) {
118
+ return {
119
+ id: "member",
120
+ handle: async (_action, payload) => {
121
+ const member2 = ctx.resolveService("member");
122
+ if (!member2) return { cards: [], text: "\u4F1A\u5458\u670D\u52A1\u4E0D\u53EF\u7528" };
123
+ const { userId } = payload ?? {};
124
+ if (!userId) return { cards: [], text: "\u7F3A\u5C11 userId" };
125
+ const points = await member2.queryPoints(userId);
126
+ return {
127
+ cards: [{ cardType: "points", data: points }],
128
+ text: `\u5F53\u524D\u79EF\u5206 ${points.balance}\uFF0C\u7B49\u7EA7 ${points.level}`
129
+ };
130
+ }
131
+ };
132
+ }
133
+
134
+ // src/server/index.ts
135
+ var member = new InMemoryMemberService({ u_1001: 860, u_1002: 120 });
136
+ var cards = {
137
+ points: meta_default
138
+ };
139
+ var serverModule = {
140
+ cards,
141
+ register(ctx) {
142
+ ctx.services.register("member", member);
143
+ ctx.tools.register(createQueryPointsTool(() => ctx.resolveService("member")));
144
+ ctx.cards.registerMap(cards);
145
+ ctx.handlers.register(createMemberHandler(ctx));
146
+ const mallBus = ctx.bus;
147
+ mallBus.subscribe("parking.coupon.used", async ({ userId, amount }) => {
148
+ await member.awardPoints(userId, Math.floor(amount / 10));
149
+ });
150
+ member.onLevelChange(({ userId, newLevel }) => {
151
+ void mallBus.publish("member.level.changed", { userId, newLevel });
152
+ });
153
+ }
154
+ };
155
+
156
+ export {
157
+ InMemoryMemberService,
158
+ meta_default,
159
+ serverModule
160
+ };
@@ -0,0 +1,33 @@
1
+ // src/client/cards/points/index.tsx
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ function PointsCard({ data }) {
4
+ const levelLabel = {
5
+ silver: "\u94F6\u5361",
6
+ gold: "\u91D1\u5361",
7
+ platinum: "\u94C2\u91D1\u5361"
8
+ };
9
+ return /* @__PURE__ */ jsxs("div", { className: "mall-card mall-card--points", children: [
10
+ /* @__PURE__ */ jsx("div", { className: "mall-card__title", children: "\u4F1A\u5458\u79EF\u5206" }),
11
+ /* @__PURE__ */ jsx("div", { className: "mall-card__balance", children: data.balance }),
12
+ /* @__PURE__ */ jsxs("div", { className: "mall-card__meta", children: [
13
+ levelLabel[data.level],
14
+ " \xB7 \u7528\u6237 ",
15
+ data.userId
16
+ ] })
17
+ ] });
18
+ }
19
+
20
+ // src/client/index.ts
21
+ var cards = {
22
+ points: PointsCard
23
+ };
24
+ var clientModule = {
25
+ cards,
26
+ register(ctx) {
27
+ ctx.cards.registerMap(cards);
28
+ }
29
+ };
30
+
31
+ export {
32
+ clientModule
33
+ };
@@ -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 points: CardComponent;
8
+ };
9
+ register(ctx: ClientContext): void;
10
+ };
11
+
12
+ export { clientModule };
@@ -0,0 +1,6 @@
1
+ import {
2
+ clientModule
3
+ } from "../chunk-OXXT6F5B.js";
4
+ export {
5
+ clientModule
6
+ };
@@ -0,0 +1,7 @@
1
+ export { InMemoryMemberService, MemberService, pointsMeta, 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
+ InMemoryMemberService,
3
+ meta_default,
4
+ serverModule
5
+ } from "./chunk-F6FHBH3N.js";
6
+ import {
7
+ clientModule
8
+ } from "./chunk-OXXT6F5B.js";
9
+ export {
10
+ InMemoryMemberService,
11
+ clientModule,
12
+ meta_default as pointsMeta,
13
+ serverModule
14
+ };
@@ -0,0 +1,82 @@
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 { MemberQueryPort, MemberSettlementPort, Points, DiscountRule } from '@tbox.cn/app-contracts-mall';
7
+
8
+ /**
9
+ * MemberService:商圈会员完整实现。
10
+ * extends 契约 Port(保证满足消费方子集约束);完整接口只在本模块包内。
11
+ */
12
+ interface MemberService extends MemberQueryPort, MemberSettlementPort {
13
+ /** 积分变动记录(单测/观测用) */
14
+ audit: {
15
+ action: string;
16
+ userId: string;
17
+ amount: number;
18
+ key?: string;
19
+ }[];
20
+ }
21
+ /** 内存实现(PoC) */
22
+ declare class InMemoryMemberService implements MemberService {
23
+ private balances;
24
+ private levels;
25
+ private idempotentKeys;
26
+ audit: {
27
+ action: string;
28
+ userId: string;
29
+ amount: number;
30
+ key?: string;
31
+ }[];
32
+ constructor(initial?: Record<string, number>);
33
+ queryPoints(userId: string): Promise<Points>;
34
+ queryDiscount(userId: string): Promise<DiscountRule | undefined>;
35
+ deductPoints(userId: string, amount: number, idempotencyKey: string): Promise<boolean>;
36
+ refundPoints(userId: string, amount: number, idempotencyKey: string): Promise<boolean>;
37
+ awardPoints(userId: string, amount: number): Promise<boolean>;
38
+ private levelOf;
39
+ private maybeUpgrade;
40
+ private levelChangeListeners;
41
+ onLevelChange(fn: (e: {
42
+ userId: string;
43
+ newLevel: 'silver' | 'gold' | 'platinum';
44
+ }) => void): void;
45
+ }
46
+
47
+ declare const dataSchema: z.ZodObject<{
48
+ userId: z.ZodString;
49
+ balance: z.ZodNumber;
50
+ level: z.ZodEnum<["silver", "gold", "platinum"]>;
51
+ }, "strip", z.ZodTypeAny, {
52
+ userId: string;
53
+ balance: number;
54
+ level: "silver" | "gold" | "platinum";
55
+ }, {
56
+ userId: string;
57
+ balance: number;
58
+ level: "silver" | "gold" | "platinum";
59
+ }>;
60
+ declare const meta: CardMeta<typeof dataSchema>;
61
+
62
+ /** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
63
+ declare const serverModule: {
64
+ cards: {
65
+ readonly points: _tbox_cn_app_contracts.CardMeta<zod.ZodObject<{
66
+ userId: zod.ZodString;
67
+ balance: zod.ZodNumber;
68
+ level: zod.ZodEnum<["silver", "gold", "platinum"]>;
69
+ }, "strip", zod.ZodTypeAny, {
70
+ userId: string;
71
+ balance: number;
72
+ level: "silver" | "gold" | "platinum";
73
+ }, {
74
+ userId: string;
75
+ balance: number;
76
+ level: "silver" | "gold" | "platinum";
77
+ }>>;
78
+ };
79
+ register(ctx: ServerContext): void;
80
+ };
81
+
82
+ export { InMemoryMemberService, type MemberService, meta as pointsMeta, serverModule };
@@ -0,0 +1,10 @@
1
+ import {
2
+ InMemoryMemberService,
3
+ meta_default,
4
+ serverModule
5
+ } from "../chunk-F6FHBH3N.js";
6
+ export {
7
+ InMemoryMemberService,
8
+ meta_default as pointsMeta,
9
+ serverModule
10
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@tbox.cn/app-module-member",
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,19 @@
1
+ import type { PointsCardData } from '../../../server/cards/points/meta';
2
+
3
+ /** 会员积分卡组件 */
4
+ export function PointsCard({ data }: { data: PointsCardData; cardId: string; isHistory: boolean }) {
5
+ const levelLabel: Record<PointsCardData['level'], string> = {
6
+ silver: '银卡',
7
+ gold: '金卡',
8
+ platinum: '铂金卡',
9
+ };
10
+ return (
11
+ <div className="mall-card mall-card--points">
12
+ <div className="mall-card__title">会员积分</div>
13
+ <div className="mall-card__balance">{data.balance}</div>
14
+ <div className="mall-card__meta">
15
+ {levelLabel[data.level]} · 用户 {data.userId}
16
+ </div>
17
+ </div>
18
+ );
19
+ }
@@ -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 { PointsCard } from './cards/points';
4
+
5
+ /** 客户端卡片组件 map(key 约定 = cardType;CLI sync 装配读取) */
6
+ const cards = {
7
+ points: PointsCard 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,6 @@
1
+ /**
2
+ * 模块主入口(package.json exports "." 指向)。
3
+ * 消费方通常走 ./server / ./client 子路径;主入口聚合导出便于直接引用。
4
+ */
5
+ export * from './server';
6
+ export * from './client';
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import type { CardMeta } from '@tbox.cn/app-contracts';
3
+
4
+ export const dataSchema = z.object({
5
+ userId: z.string(),
6
+ balance: z.number(),
7
+ level: z.enum(['silver', 'gold', 'platinum']),
8
+ });
9
+
10
+ export type PointsCardData = z.infer<typeof dataSchema>;
11
+
12
+ const meta: CardMeta<typeof dataSchema> = {
13
+ cardType: 'points',
14
+ dataSchema,
15
+ allowedTools: ['queryPoints'],
16
+ displayName: '会员积分卡',
17
+ description: '展示会员当前积分余额与等级',
18
+ sampleData: { userId: 'u_1001', balance: 860, level: 'gold' },
19
+ schemaVersion: 1,
20
+ };
21
+
22
+ export default meta;
@@ -0,0 +1,24 @@
1
+ import type { HandlerDefinition } from '@tbox.cn/app-sdk/server';
2
+ import type { MemberQueryPort } from '@tbox.cn/app-contracts-mall';
3
+ import type { ServerContext } from '@tbox.cn/app-sdk/server';
4
+
5
+ /**
6
+ * member handler:查积分 → 出 points 卡。
7
+ * 由 scenario / intent 路由按需 dispatch;LLM 路径走 queryPoints tool。
8
+ */
9
+ export function createMemberHandler(ctx: ServerContext): HandlerDefinition {
10
+ return {
11
+ id: 'member',
12
+ handle: async (_action, payload) => {
13
+ const member = ctx.resolveService<MemberQueryPort>('member');
14
+ if (!member) return { cards: [], text: '会员服务不可用' };
15
+ const { userId } = (payload ?? {}) as { userId?: string };
16
+ if (!userId) return { cards: [], text: '缺少 userId' };
17
+ const points = await member.queryPoints(userId);
18
+ return {
19
+ cards: [{ cardType: 'points', data: points as unknown as Record<string, unknown> }],
20
+ text: `当前积分 ${points.balance},等级 ${points.level}`,
21
+ };
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,45 @@
1
+ import type { ServerContext, ServerModule, TypedBus } from '@tbox.cn/app-sdk/server';
2
+ import type { MallEventMap } from '@tbox.cn/app-contracts-mall';
3
+ import { InMemoryMemberService } from './service';
4
+ import pointsMeta from './cards/points/meta';
5
+ import { createQueryPointsTool } from './tool';
6
+ import { createMemberHandler } from './handler';
7
+
8
+ export { InMemoryMemberService } from './service';
9
+ export type { MemberService } from './service';
10
+ export { default as pointsMeta } from './cards/points/meta';
11
+
12
+ /**
13
+ * 模块级单例(B14):实例在模块包加载时创建,serverModule.register 幂等(重复调用共享状态)。
14
+ * 服务实例生命周期 = 模块包加载期,而非 register 调用次数。
15
+ */
16
+ const member = new InMemoryMemberService({ u_1001: 860, u_1002: 120 });
17
+
18
+ /** 卡片 meta 声明 map(key 约定 = meta.cardType;数据 + 类型源) */
19
+ const cards = {
20
+ points: pointsMeta,
21
+ } as const;
22
+
23
+ /** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
24
+ export const serverModule = {
25
+ cards,
26
+ register(ctx: ServerContext): void {
27
+ ctx.services.register('member', member);
28
+ ctx.tools.register(createQueryPointsTool(() => ctx.resolveService('member')));
29
+ ctx.cards.registerMap(cards);
30
+ ctx.handlers.register(createMemberHandler(ctx));
31
+
32
+ // 类型化事件总线:以 MallEventMap 收窄(keyof 约束载荷类型,编译期对齐)
33
+ const mallBus = ctx.bus as unknown as TypedBus<MallEventMap>;
34
+
35
+ // 跨模块异步通知:parking 核销停车券 → member 奖积分(member 不依赖 parking)
36
+ mallBus.subscribe('parking.coupon.used', async ({ userId, amount }) => {
37
+ await member.awardPoints(userId, Math.floor(amount / 10));
38
+ });
39
+
40
+ // 等级变更对外发布(member 发事件,其他模块订阅);publish 为 async(B1,fire-and-forget)
41
+ member.onLevelChange(({ userId, newLevel }) => {
42
+ void mallBus.publish('member.level.changed', { userId, newLevel });
43
+ });
44
+ },
45
+ } satisfies ServerModule;
@@ -0,0 +1,95 @@
1
+ import type { MemberQueryPort, MemberSettlementPort, Points } from '@tbox.cn/app-contracts-mall';
2
+ import type { DiscountRule } from '@tbox.cn/app-contracts-mall';
3
+
4
+ /**
5
+ * MemberService:商圈会员完整实现。
6
+ * extends 契约 Port(保证满足消费方子集约束);完整接口只在本模块包内。
7
+ */
8
+ export interface MemberService extends MemberQueryPort, MemberSettlementPort {
9
+ /** 积分变动记录(单测/观测用) */
10
+ audit: { action: string; userId: string; amount: number; key?: string }[];
11
+ }
12
+
13
+ /** 内存实现(PoC) */
14
+ export class InMemoryMemberService implements MemberService {
15
+ private balances = new Map<string, number>();
16
+ private levels = new Map<string, 'silver' | 'gold' | 'platinum'>();
17
+ private idempotentKeys = new Set<string>();
18
+ audit: { action: string; userId: string; amount: number; key?: string }[] = [];
19
+
20
+ constructor(initial: Record<string, number> = {}) {
21
+ for (const [userId, balance] of Object.entries(initial)) {
22
+ this.balances.set(userId, balance);
23
+ }
24
+ }
25
+
26
+ async queryPoints(userId: string): Promise<Points> {
27
+ return {
28
+ userId,
29
+ balance: this.balances.get(userId) ?? 0,
30
+ level: this.levelOf(userId),
31
+ };
32
+ }
33
+
34
+ async queryDiscount(userId: string): Promise<DiscountRule | undefined> {
35
+ const level = this.levelOf(userId);
36
+ const map: Record<string, DiscountRule> = {
37
+ silver: { level: 'silver', discount: 0.95 },
38
+ gold: { level: 'gold', discount: 0.9 },
39
+ platinum: { level: 'platinum', discount: 0.85 },
40
+ };
41
+ return map[level];
42
+ }
43
+
44
+ async deductPoints(userId: string, amount: number, idempotencyKey: string): Promise<boolean> {
45
+ if (this.idempotentKeys.has(idempotencyKey)) return true; // 幂等
46
+ const current = this.balances.get(userId) ?? 0;
47
+ if (amount <= 0 || current < amount) return false;
48
+ this.balances.set(userId, current - amount);
49
+ this.idempotentKeys.add(idempotencyKey);
50
+ this.audit.push({ action: 'deduct', userId, amount, key: idempotencyKey });
51
+ this.maybeUpgrade(userId);
52
+ return true;
53
+ }
54
+
55
+ async refundPoints(userId: string, amount: number, idempotencyKey: string): Promise<boolean> {
56
+ if (this.idempotentKeys.has(`refund:${idempotencyKey}`)) return true;
57
+ const current = this.balances.get(userId) ?? 0;
58
+ this.balances.set(userId, current + amount);
59
+ this.idempotentKeys.add(`refund:${idempotencyKey}`);
60
+ this.audit.push({ action: 'refund', userId, amount, key: idempotencyKey });
61
+ return true;
62
+ }
63
+
64
+ async awardPoints(userId: string, amount: number): Promise<boolean> {
65
+ const current = this.balances.get(userId) ?? 0;
66
+ this.balances.set(userId, current + amount);
67
+ this.audit.push({ action: 'award', userId, amount });
68
+ this.maybeUpgrade(userId);
69
+ return true;
70
+ }
71
+
72
+ private levelOf(userId: string): 'silver' | 'gold' | 'platinum' {
73
+ const cached = this.levels.get(userId);
74
+ if (cached) return cached;
75
+ const balance = this.balances.get(userId) ?? 0;
76
+ if (balance >= 1000) return 'platinum';
77
+ if (balance >= 300) return 'gold';
78
+ return 'silver';
79
+ }
80
+
81
+ private maybeUpgrade(userId: string): void {
82
+ const newLevel = this.levelOf(userId);
83
+ const prev = this.levels.get(userId);
84
+ this.levels.set(userId, newLevel);
85
+ if (prev && prev !== newLevel) {
86
+ // 等级变更事件由 serverModule.register 订阅侧转发(保持 service 与 bus 解耦)
87
+ this.levelChangeListeners.forEach((fn) => fn({ userId, newLevel }));
88
+ }
89
+ }
90
+
91
+ private levelChangeListeners = new Set<(e: { userId: string; newLevel: 'silver' | 'gold' | 'platinum' }) => void>();
92
+ onLevelChange(fn: (e: { userId: string; newLevel: 'silver' | 'gold' | 'platinum' }) => void): void {
93
+ this.levelChangeListeners.add(fn);
94
+ }
95
+ }
@@ -0,0 +1,31 @@
1
+ import { createTool } from '@tbox.cn/app-sdk/server';
2
+ import { z } from 'zod';
3
+ import type { MemberQueryPort } from '@tbox.cn/app-contracts-mall';
4
+
5
+ /**
6
+ * 积分查询工具(LLM function calling;只读查询)。
7
+ * 经 ToolRegistry 注册,LLM 调用后由 cardResolver(allowedTools 绑定)自动出 points 卡。
8
+ */
9
+ export function createQueryPointsTool(getMember: () => MemberQueryPort | undefined) {
10
+ return createTool({
11
+ id: 'queryPoints',
12
+ description: '查询会员当前积分余额与等级',
13
+ inputSchema: z.object({
14
+ userId: z.string().describe('会员 userId'),
15
+ }),
16
+ outputSchema: z.object({
17
+ userId: z.string(),
18
+ balance: z.number(),
19
+ level: z.enum(['silver', 'gold', 'platinum']),
20
+ }),
21
+ execute: async ({ userId }: { userId: string }): Promise<{
22
+ userId: string;
23
+ balance: number;
24
+ level: 'silver' | 'gold' | 'platinum';
25
+ }> => {
26
+ const member = getMember();
27
+ if (!member) return { userId, balance: 0, level: 'silver' };
28
+ return member.queryPoints(userId);
29
+ },
30
+ });
31
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "module-member",
4
+ "version": "0.1.0",
5
+ "kind": "business",
6
+ "risk": { "level": "medium", "writeBoundary": "source" },
7
+ "distribution": { "defaultMode": "codegen" },
8
+ "contributes": {
9
+ "handlers": ["member"],
10
+ "tools": ["queryPoints"],
11
+ "cards": [{ "cardType": "points", "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,53 @@
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-member 自注册与积分服务', () => {
6
+ it('serverModule.register 注册 tool/card/service/handler', () => {
7
+ const ctx = createServerContext();
8
+ serverModule.register(ctx);
9
+
10
+ expect(ctx.tools.has('queryPoints')).toBe(true);
11
+ expect(ctx.cards.has('points')).toBe(true);
12
+ expect(ctx.handlers.has('member')).toBe(true);
13
+ expect(ctx.resolveService('member')).toBeDefined();
14
+ });
15
+
16
+ it('queryPoints 返回初始积分', async () => {
17
+ const ctx = createServerContext();
18
+ serverModule.register(ctx);
19
+ const member = ctx.resolveService<{ queryPoints(u: string): Promise<{ balance: number; level: string }> }>('member')!;
20
+ const points = await member.queryPoints('u_1001');
21
+ expect(points.balance).toBe(860);
22
+ expect(points.level).toBe('gold');
23
+ });
24
+
25
+ it('deductPoints 幂等(同 idempotencyKey 只扣一次)', async () => {
26
+ const ctx = createServerContext();
27
+ serverModule.register(ctx);
28
+ const member = ctx.resolveService<{ deductPoints(u: string, a: number, k: string): Promise<boolean> }>('member')!;
29
+
30
+ expect(await member.deductPoints('u_1001', 100, 'key-1')).toBe(true);
31
+ expect(await member.deductPoints('u_1001', 100, 'key-1')).toBe(true); // 幂等
32
+ const after = await ctx.resolveService<{ queryPoints(u: string): Promise<{ balance: number }> }>('member')!.queryPoints('u_1001');
33
+ expect(after.balance).toBe(760); // 只扣一次
34
+ });
35
+
36
+ it('余额不足扣减失败', async () => {
37
+ const ctx = createServerContext();
38
+ serverModule.register(ctx);
39
+ const member = ctx.resolveService<{ deductPoints(u: string, a: number, k: string): Promise<boolean> }>('member')!;
40
+ expect(await member.deductPoints('u_1001', 99999, 'key-x')).toBe(false);
41
+ });
42
+
43
+ it('parking.coupon.used 事件 → 奖积分(跨模块解耦)', async () => {
44
+ const ctx = createServerContext();
45
+ serverModule.register(ctx);
46
+ // 模拟 parking 发布事件
47
+ ctx.bus.emit('parking.coupon.used', { userId: 'u_1002', couponId: 'c_1', amount: 50 });
48
+ await new Promise((r) => setTimeout(r, 10)); // 等待异步订阅
49
+ const member = ctx.resolveService<{ queryPoints(u: string): Promise<{ balance: number }> }>('member')!;
50
+ const after = await member.queryPoints('u_1002');
51
+ expect(after.balance).toBe(120 + Math.floor(50 / 10));
52
+ });
53
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "types": ["node"]
6
+ },
7
+ "include": ["src", "tests"]
8
+ }
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
+ });