@tbox.cn/app-module-member 0.2.0 → 0.8.7
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 +99 -11
- package/dist/chunk-EPQEF43P.js +1581 -0
- package/dist/chunk-QWGRWD5C.js +766 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +12 -2
- package/dist/member-372B74DY.css +1740 -0
- package/dist/server/index.d.ts +657 -286
- package/dist/server/index.js +11 -1
- package/package.json +25 -16
- package/skills/member/SKILL.md +141 -0
- package/skills/member/references/enrollment.md +34 -0
- package/skills/member/references/points-and-card.md +24 -0
- package/src/client/cards/index.ts +10 -0
- package/src/client/cards/member-card-summary/index.tsx +375 -0
- package/src/client/cards/member-enrollment-cta/index.tsx +61 -0
- package/src/client/cards/member-enrollment-form/index.tsx +181 -0
- package/src/client/cards/member-enrollment-status/index.tsx +65 -0
- package/src/client/cards/member-info/index.tsx +60 -0
- package/src/client/cards/member-login-cta/index.tsx +81 -0
- package/src/client/cards/member-point-usage-list/index.tsx +53 -0
- package/src/client/cards/view.ts +39 -0
- package/src/client/components/ImageWithFallback.tsx +38 -0
- package/src/client/index.ts +6 -4
- package/src/client/styles/member.css +1740 -0
- package/src/client/types/css.d.ts +2 -0
- package/src/server/action.ts +348 -0
- package/src/server/cards/index.ts +7 -7
- package/src/server/cards/member-card-summary/meta.ts +26 -5
- package/src/server/cards/member-card-summary/samples.ts +272 -0
- package/src/server/cards/member-enrollment-cta/meta.ts +19 -0
- package/src/server/cards/member-enrollment-form/meta.ts +10 -6
- package/src/server/cards/member-enrollment-status/meta.ts +5 -6
- package/src/server/cards/member-info/meta.ts +1 -1
- package/src/server/cards/member-login-cta/meta.ts +16 -0
- package/src/server/cards/member-point-usage-list/meta.ts +2 -3
- package/src/server/handler.ts +159 -119
- package/src/server/index.ts +129 -50
- package/src/server/ports.ts +66 -0
- package/src/server/service.ts +94 -80
- package/src/server/status.ts +87 -0
- package/src/server/subject-binding.ts +19 -0
- package/src/server/tool.ts +469 -99
- package/tbox.module.json +124 -0
- package/tests/cards-render.test.tsx +526 -0
- package/tests/enrollment.test.ts +121 -0
- package/tests/ports-resolve.test.ts +95 -0
- package/tests/preference-memory.test.ts +86 -0
- package/tests/samples.test.ts +61 -0
- package/tests/service.test.ts +593 -35
- package/tests/skills.test.ts +65 -0
- package/tests/snapshot-service.test.ts +75 -0
- package/tests/status.test.ts +127 -0
- package/tests/view.test.ts +33 -0
- package/tsup.config.ts +9 -1
- package/dist/chunk-JGZGLFO6.js +0 -111
- package/dist/chunk-ZV6B36AO.js +0 -613
- package/src/client/cards/index.tsx +0 -135
- package/src/server/cards/member-coupon-detail/meta.ts +0 -19
- package/src/server/cards/member-coupon-list/meta.ts +0 -18
- package/tbox.component.json +0 -28
package/src/server/handler.ts
CHANGED
|
@@ -1,138 +1,178 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* member handler
|
|
3
|
-
* - 查积分/信息/卡 → 出对应卡(LLM
|
|
4
|
-
* -
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
2
|
+
* member handler(011 K2;015:写动作拆至 action.ts,此处只保留意图路径出卡)。
|
|
3
|
+
* - 查积分/信息/卡 → 出对应卡(LLM 路径走同名工具)。
|
|
4
|
+
* - 会员偏好长期记忆(017 PRD):三项声明式 intent → ctx.memory 保存 + continueWith 续接导购。
|
|
5
|
+
* - 上下文统一化:userId 来源 = 运行身份(reqCtx.identity.userId,handler 第四参)——
|
|
6
|
+
* 旧 payload.userId 是模型注入面(P4-3),已删除;偏好分支同经 reqCtx 显式读取。
|
|
7
7
|
*/
|
|
8
8
|
import type { HandlerDefinition, ServerContext } from '@tbox.cn/app-sdk/server';
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
import {
|
|
10
|
+
MEMBER_PREFERENCE_PROMPTS,
|
|
11
|
+
MEMBER_PREFERENCE_RECOMMENDATION_QUERIES,
|
|
12
|
+
MEMBER_PREFERENCE_VALUES,
|
|
13
|
+
parseMallScope,
|
|
14
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
15
|
+
import type { MemberPreferenceValue } from '@tbox.cn/app-contracts-mall';
|
|
16
|
+
import { MEMBER_QUERY_NOTICES, resolveMemberStatus } from './status';
|
|
17
|
+
import { memberPrivacyRefusal } from './tool';
|
|
18
|
+
import { resolveMemberBenefitsPort, resolveMemberCardPort } from './ports';
|
|
19
|
+
|
|
20
|
+
function matchMemberPreference(content: string) {
|
|
21
|
+
return MEMBER_PREFERENCE_PROMPTS.find((prompt) => content === prompt.query);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 偏好确认回执文案(第二人称):按钮值是用户口吻(写记忆保留第一人称),
|
|
25
|
+
* 助手复述须转换人称;Record 穷尽性保证枚举扩容时编译期强制补齐。 */
|
|
26
|
+
const MEMBER_PREFERENCE_ACK_TEXTS: Record<MemberPreferenceValue, string> = {
|
|
27
|
+
'我常来买衣服': '你常来买衣服',
|
|
28
|
+
'我常带娃来玩': '你常带娃来玩',
|
|
29
|
+
'我常来吃美食': '你常来吃美食',
|
|
30
|
+
};
|
|
12
31
|
|
|
13
32
|
export function createMemberHandler(ctx: ServerContext): HandlerDefinition {
|
|
14
33
|
return {
|
|
15
34
|
id: 'member',
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
35
|
+
// match:'exact':偏好按钮发出的是固定整句值(member-card-summary 按钮组),
|
|
36
|
+
// 整句全等命中——不入 BM25 词袋(相似查询「2楼有啥美食可逛」曾被词袋误吞,F3)
|
|
37
|
+
intents: [{ patterns: [...MEMBER_PREFERENCE_VALUES], match: 'exact' }],
|
|
38
|
+
handle: async (action, payload, _ctx, reqCtx) => {
|
|
39
|
+
if (action === 'intent') {
|
|
40
|
+
const content = typeof (payload as { content?: unknown } | undefined)?.content === 'string'
|
|
41
|
+
? (payload as { content: string }).content.trim()
|
|
42
|
+
: '';
|
|
43
|
+
const prompt = matchMemberPreference(content);
|
|
44
|
+
// 洋葱定律 1:未识别 = 失败 = 必须放行(不再出 12 字死文案短路整轮)。
|
|
45
|
+
// exact 路由下正常不可达(全等已保证命中),防御性兜底同样放行 LLM 正常对话。
|
|
46
|
+
if (!prompt) return { cards: [], continueWith: { content } };
|
|
20
47
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const usage = await member.listPointUsage(userId, 1, 10);
|
|
26
|
-
return {
|
|
27
|
-
cards: [
|
|
28
|
-
{
|
|
29
|
-
cardType: 'member-point-usage-list',
|
|
30
|
-
data: {
|
|
31
|
-
userId,
|
|
32
|
-
balance: points.balance,
|
|
33
|
-
level: points.level,
|
|
34
|
-
items: usage.items,
|
|
35
|
-
total: usage.total,
|
|
36
|
-
} as unknown as Record<string, unknown>,
|
|
37
|
-
},
|
|
38
|
-
],
|
|
39
|
-
text: `当前积分 ${points.balance},等级 ${points.level}`,
|
|
40
|
-
};
|
|
48
|
+
// 上下文统一化:运行身份 = handle 第四参 reqCtx(引擎意图分发注入)
|
|
49
|
+
const identity = reqCtx?.identity;
|
|
50
|
+
if (!identity || identity.source === 'anonymous') {
|
|
51
|
+
return { cards: [], text: '登录后才能保存会员偏好' };
|
|
41
52
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const points = await member.queryPoints(userId);
|
|
45
|
-
return {
|
|
46
|
-
cards: [
|
|
47
|
-
{
|
|
48
|
-
cardType: 'member-point-usage-list',
|
|
49
|
-
data: {
|
|
50
|
-
userId,
|
|
51
|
-
balance: points.balance,
|
|
52
|
-
level: points.level,
|
|
53
|
-
items: [],
|
|
54
|
-
total: 0,
|
|
55
|
-
} as unknown as Record<string, unknown>,
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
text: `当前积分 ${points.balance},等级 ${points.level}`,
|
|
59
|
-
};
|
|
53
|
+
if (!ctx.memory || ctx.memory.capabilities?.save === false) {
|
|
54
|
+
return { cards: [], text: '偏好记忆暂不可用,请稍后再试' };
|
|
60
55
|
}
|
|
61
|
-
}
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
57
|
+
const mallId = parseMallScope(reqCtx?.attributes)?.mallId;
|
|
58
|
+
await ctx.memory.save({
|
|
59
|
+
userId: identity.userId,
|
|
60
|
+
...(mallId ? { scope: { mallId } } : {}),
|
|
61
|
+
kind: 'preference',
|
|
62
|
+
content: `会员偏好:${prompt.value}`,
|
|
63
|
+
});
|
|
74
64
|
|
|
75
|
-
export function createEnrollAction(
|
|
76
|
-
getMember: () => (MemberEnrollmentPort & MemberBenefitsPort) | undefined,
|
|
77
|
-
): { actionId: string; componentId: string; cardType: string; label: string; singleUse: boolean; inputSchema: z.ZodTypeAny; execute(input: unknown, ctx: RequestContext): Promise<CardActionResult> } {
|
|
78
|
-
return {
|
|
79
|
-
actionId: 'member.enroll',
|
|
80
|
-
componentId: 'member',
|
|
81
|
-
cardType: 'member-enrollment-form',
|
|
82
|
-
label: '提交入会',
|
|
83
|
-
singleUse: true,
|
|
84
|
-
inputSchema: enrollActionInputSchema,
|
|
85
|
-
async execute(input, ctx): Promise<CardActionResult> {
|
|
86
|
-
// 011 M1:userId 优先会话身份(dispatcher 传入 ctx;表单无输入通道时身份入会仍闭环)
|
|
87
|
-
const sessionUserId = ctx?.identity?.userId;
|
|
88
|
-
const { userId: inputUserId, name, phone } = (input ?? {}) as {
|
|
89
|
-
userId?: string;
|
|
90
|
-
name?: string;
|
|
91
|
-
phone?: string;
|
|
92
|
-
};
|
|
93
|
-
const userId = sessionUserId ?? inputUserId;
|
|
94
|
-
const member = getMember();
|
|
95
|
-
if (!userId) {
|
|
96
65
|
return {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
},
|
|
101
|
-
text: '入会失败:缺少身份信息',
|
|
66
|
+
cards: [],
|
|
67
|
+
text: `记住啦,${MEMBER_PREFERENCE_ACK_TEXTS[prompt.value]}。正在为你查找相关的门店和导购服务,请稍等。\n\n`,
|
|
68
|
+
continueWith: { content: MEMBER_PREFERENCE_RECOMMENDATION_QUERIES[prompt.value] },
|
|
102
69
|
};
|
|
103
70
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
71
|
+
|
|
72
|
+
if (!reqCtx) return { cards: [], text: '会员服务不可用(运行上下文缺失)' };
|
|
73
|
+
const query = typeof (payload as { query?: unknown } | undefined)?.query === 'string'
|
|
74
|
+
? (payload as { query: string }).query
|
|
75
|
+
: '';
|
|
76
|
+
const privacyRefusal = memberPrivacyRefusal(query);
|
|
77
|
+
if (privacyRefusal) return { cards: [], text: privacyRefusal };
|
|
78
|
+
const status = await resolveMemberStatus(reqCtx, ctx);
|
|
79
|
+
if (status.outcome === 'active') {
|
|
80
|
+
const userId = reqCtx.identity.userId;
|
|
81
|
+
switch (action) {
|
|
82
|
+
case 'query-points': {
|
|
83
|
+
// 017 槽位原子化:active 态按槽解析(card 面 + benefits 面;多次构造廉价)。
|
|
84
|
+
// 槽解析失败 → unavailable 收口(旧防御分支语义延续:不裸抛、不答「积分 0」)
|
|
85
|
+
let cardPort: Awaited<ReturnType<typeof resolveMemberCardPort>>;
|
|
86
|
+
let benefitsPort: Awaited<ReturnType<typeof resolveMemberBenefitsPort>>;
|
|
87
|
+
try {
|
|
88
|
+
[cardPort, benefitsPort] = await Promise.all([
|
|
89
|
+
resolveMemberCardPort(reqCtx, ctx),
|
|
90
|
+
resolveMemberBenefitsPort(reqCtx, ctx),
|
|
91
|
+
]);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
ctx.logger?.warn(`member handler:active 槽解析失败:err=${(error as Error).message}`);
|
|
94
|
+
return { cards: [], text: MEMBER_QUERY_NOTICES.unavailable };
|
|
95
|
+
}
|
|
96
|
+
const card = await cardPort.getCard(userId);
|
|
97
|
+
let points = { userId, balance: 0 } as Awaited<ReturnType<typeof benefitsPort.queryPoints>>;
|
|
98
|
+
let pointsUnavailable = false;
|
|
99
|
+
try {
|
|
100
|
+
points = await benefitsPort.queryPoints(userId);
|
|
101
|
+
} catch {
|
|
102
|
+
pointsUnavailable = true;
|
|
103
|
+
}
|
|
104
|
+
if (card?.presentation !== undefined) {
|
|
105
|
+
let usage: Awaited<ReturnType<typeof benefitsPort.listPointUsage>> | undefined;
|
|
106
|
+
let usageUnavailable = false;
|
|
107
|
+
if (card.pointsUrl) {
|
|
108
|
+
try {
|
|
109
|
+
usage = await benefitsPort.listPointUsage(userId, 1, 10);
|
|
110
|
+
} catch {
|
|
111
|
+
usageUnavailable = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const cardData = {
|
|
115
|
+
userId,
|
|
116
|
+
cardRef: card.cardRef,
|
|
117
|
+
cardDisplay: card.cardDisplay,
|
|
118
|
+
status: card.status,
|
|
119
|
+
...(card.levelName ? { levelName: card.levelName } : {}),
|
|
120
|
+
availablePoints: points.balance,
|
|
121
|
+
...(card.cardImageUrl ? { cardImageUrl: card.cardImageUrl } : {}),
|
|
122
|
+
...(card.pointsRuleImageUrl ? { pointsRuleImageUrl: card.pointsRuleImageUrl } : {}),
|
|
123
|
+
...(card.pointsRuleContent ? { pointsRuleContent: card.pointsRuleContent } : {}),
|
|
124
|
+
...(card.pointsUrl ? { pointsUrl: card.pointsUrl } : {}),
|
|
125
|
+
...(card.presentation ? { presentation: card.presentation } : {}),
|
|
126
|
+
...(card.progress ? { progress: card.progress } : {}),
|
|
127
|
+
...(card.benefits ? { benefits: [...card.benefits] } : {}),
|
|
128
|
+
} as Record<string, unknown>;
|
|
129
|
+
return {
|
|
130
|
+
cards: [{ cardType: 'member-card-summary', data: cardData }],
|
|
131
|
+
text: pointsUnavailable
|
|
132
|
+
? '会员卡可正常查看,但当前积分余额暂不可用,暂无最近积分流水记录'
|
|
133
|
+
: usageUnavailable
|
|
134
|
+
? `当前积分 ${points.balance},暂无最近积分流水记录`
|
|
135
|
+
: usage && usage.items.length > 0
|
|
136
|
+
? `当前积分 ${points.balance},最近流水:${usage.items.map((item) => `${item.title}${item.amount}分${item.usedAt ? `(${item.usedAt})` : ''}`).join(';')}`
|
|
137
|
+
: `当前积分 ${points.balance},暂未查询到最近积分流水`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const usage = await benefitsPort.listPointUsage(userId, 1, 10);
|
|
141
|
+
const usageText = usage.items.length > 0
|
|
142
|
+
? `,最近流水:${usage.items.map((item) => `${item.title}${item.amount}分${item.usedAt ? `(${item.usedAt})` : ''}`).join(';')}`
|
|
143
|
+
: ',暂未查询到最近积分流水';
|
|
144
|
+
if (!card) {
|
|
145
|
+
return {
|
|
146
|
+
cards: [],
|
|
147
|
+
text: `当前积分 ${points.balance}${points.membershipLabel ? `,会员卡 ${points.membershipLabel}` : ''}${usageText}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
cards: [{ cardType: 'member-card-summary', data: {
|
|
152
|
+
userId,
|
|
153
|
+
cardRef: card.cardRef,
|
|
154
|
+
cardDisplay: card.cardDisplay,
|
|
155
|
+
status: card.status,
|
|
156
|
+
...(card.levelName ? { levelName: card.levelName } : {}),
|
|
157
|
+
availablePoints: points.balance,
|
|
158
|
+
...(card.cardImageUrl ? { cardImageUrl: card.cardImageUrl } : {}),
|
|
159
|
+
...(card.pointsRuleImageUrl ? { pointsRuleImageUrl: card.pointsRuleImageUrl } : {}),
|
|
160
|
+
...(card.pointsRuleContent ? { pointsRuleContent: card.pointsRuleContent } : {}),
|
|
161
|
+
...(card.pointsUrl ? { pointsUrl: card.pointsUrl } : {}),
|
|
162
|
+
...(card.presentation ? { presentation: card.presentation } : {}),
|
|
163
|
+
...(card.progress ? { progress: card.progress } : {}),
|
|
164
|
+
...(card.benefits ? { benefits: [...card.benefits] } : {}),
|
|
165
|
+
} as Record<string, unknown> }],
|
|
166
|
+
text: `当前积分 ${points.balance}${points.membershipLabel ? `,会员卡 ${points.membershipLabel}` : ''}${usageText}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
default: {
|
|
170
|
+
return { cards: [], text: '不支持的会员操作' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
122
173
|
}
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
card: {
|
|
126
|
-
cardType: 'member-enrollment-status',
|
|
127
|
-
data: {
|
|
128
|
-
userId,
|
|
129
|
-
success: true,
|
|
130
|
-
...(result.memberRef ? { memberRef: result.memberRef } : {}),
|
|
131
|
-
state: 'active',
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
text: `入会成功!当前积分 ${points.balance},等级 ${points.level}`,
|
|
135
|
-
};
|
|
174
|
+
// 非 active:门禁文案与工具路径同源(意图路径不再对未入会/未登录答「积分 0」)
|
|
175
|
+
return { cards: [], text: MEMBER_QUERY_NOTICES[status.outcome] };
|
|
136
176
|
},
|
|
137
177
|
};
|
|
138
178
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,98 +1,177 @@
|
|
|
1
|
-
import type { ServerContext, ServerModule
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
1
|
+
import type { ServerContext, ServerModule } from '@tbox.cn/app-sdk/server';
|
|
2
|
+
import { loadModuleSkills, withCardEmissionHint, withEarlyExecution } from '@tbox.cn/app-sdk/server';
|
|
3
|
+
import type { RequestContext } from '@tbox.cn/app-contracts';
|
|
4
|
+
import { MEMBER_SNAPSHOT_SERVICE, type MemberSnapshotPort } from '@tbox.cn/app-contracts-mall';
|
|
5
5
|
import memberInfoMeta from './cards/member-info/meta';
|
|
6
6
|
import memberCardSummaryMeta from './cards/member-card-summary/meta';
|
|
7
|
-
import memberCouponListMeta from './cards/member-coupon-list/meta';
|
|
8
|
-
import memberCouponDetailMeta from './cards/member-coupon-detail/meta';
|
|
9
7
|
import memberPointUsageListMeta from './cards/member-point-usage-list/meta';
|
|
10
8
|
import memberEnrollmentFormMeta from './cards/member-enrollment-form/meta';
|
|
11
9
|
import memberEnrollmentStatusMeta from './cards/member-enrollment-status/meta';
|
|
10
|
+
import memberEnrollmentCtaMeta from './cards/member-enrollment-cta/meta';
|
|
11
|
+
import memberLoginCtaMeta from './cards/member-login-cta/meta';
|
|
12
12
|
import {
|
|
13
13
|
createQueryPointsTool,
|
|
14
14
|
createQueryMemberInfoTool,
|
|
15
15
|
createQueryMemberCardTool,
|
|
16
|
-
createQueryMemberCouponsTool,
|
|
17
16
|
createQueryEnrollmentFormTool,
|
|
17
|
+
createQueryEnrollmentCtaTool,
|
|
18
|
+
createQueryLoginCtaTool,
|
|
18
19
|
} from './tool';
|
|
19
|
-
import { createMemberHandler
|
|
20
|
+
import { createMemberHandler } from './handler';
|
|
21
|
+
import { createMemberActions } from './action';
|
|
22
|
+
import { createSubjectBinding } from './subject-binding';
|
|
23
|
+
import {
|
|
24
|
+
resolveMemberAccountPort,
|
|
25
|
+
resolveMemberBenefitsPort,
|
|
26
|
+
resolveMemberCardPort,
|
|
27
|
+
resolveMemberEnrollmentPort,
|
|
28
|
+
resolveMemberProfilePort,
|
|
29
|
+
} from './ports';
|
|
30
|
+
import { resolveMemberStatus } from './status';
|
|
20
31
|
|
|
21
32
|
export { InMemoryMemberService } from './service';
|
|
22
33
|
export type { MemberService } from './service';
|
|
34
|
+
export {
|
|
35
|
+
resolveMemberAccountPort,
|
|
36
|
+
resolveMemberProfilePort,
|
|
37
|
+
resolveMemberCardPort,
|
|
38
|
+
resolveMemberBenefitsPort,
|
|
39
|
+
resolveMemberEnrollmentPort,
|
|
40
|
+
} from './ports';
|
|
41
|
+
export type { MemberAccountService, MemberEnrollmentService } from './ports';
|
|
23
42
|
export { memberCards } from './cards';
|
|
24
43
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
let serverCtx: ServerContext | undefined;
|
|
45
|
+
const getCtx = (): ServerContext => {
|
|
46
|
+
if (!serverCtx) throw new Error('member 模块未注册(ServerContext 未绑定)');
|
|
47
|
+
return serverCtx;
|
|
48
|
+
};
|
|
49
|
+
/** 请求级 member 槽服务集(017 槽位原子化:五槽 resolver 注入工具/动作;
|
|
50
|
+
* 上下文统一化:显式传 reqCtx,替代 ctx.auth 全局单例) */
|
|
51
|
+
const memberPorts = {
|
|
52
|
+
getAccount: (reqCtx: RequestContext) => resolveMemberAccountPort(reqCtx, getCtx()),
|
|
53
|
+
getProfile: (reqCtx: RequestContext) => resolveMemberProfilePort(reqCtx, getCtx()),
|
|
54
|
+
getCard: (reqCtx: RequestContext) => resolveMemberCardPort(reqCtx, getCtx()),
|
|
55
|
+
getBenefits: (reqCtx: RequestContext) => resolveMemberBenefitsPort(reqCtx, getCtx()),
|
|
56
|
+
getEnrollment: (reqCtx: RequestContext) => resolveMemberEnrollmentPort(reqCtx, getCtx()),
|
|
57
|
+
};
|
|
29
58
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
59
|
+
/** 工具实例顶层创建;每次 execute 解析当前请求的真实 C 端 Provider。
|
|
60
|
+
* W2(2026-09-01):出卡工具创建点单点 wrap(description 追加预告知句)——
|
|
61
|
+
* 注册即 wrapped 实例;CTA/表单卡承载操作入口带 actions 句。
|
|
62
|
+
* PR-2:三个纯查询工具(只读/幂等/execCtx 仅消费 requestContext)标注尽早执行
|
|
63
|
+
* 白名单——env TBOX_EARLY_TOOL_EXECUTION=1 时参数流完即预执行;缺省 off 零行为。 */
|
|
64
|
+
const queryPointsTool = withEarlyExecution(
|
|
65
|
+
withCardEmissionHint(createQueryPointsTool(memberPorts)),
|
|
66
|
+
);
|
|
67
|
+
const queryMemberInfoTool = withEarlyExecution(
|
|
68
|
+
withCardEmissionHint(createQueryMemberInfoTool(memberPorts)),
|
|
69
|
+
);
|
|
70
|
+
const queryMemberCardTool = withEarlyExecution(
|
|
71
|
+
withCardEmissionHint(createQueryMemberCardTool(memberPorts)),
|
|
72
|
+
);
|
|
73
|
+
const queryEnrollmentFormTool = withCardEmissionHint(createQueryEnrollmentFormTool(memberPorts), {
|
|
74
|
+
actions: true,
|
|
75
|
+
});
|
|
76
|
+
const queryEnrollmentCtaTool = withCardEmissionHint(createQueryEnrollmentCtaTool(memberPorts), {
|
|
77
|
+
actions: true,
|
|
78
|
+
});
|
|
79
|
+
const queryLoginCtaTool = withCardEmissionHint(createQueryLoginCtaTool(memberPorts), {
|
|
80
|
+
actions: true,
|
|
81
|
+
});
|
|
36
82
|
|
|
37
83
|
/** 卡片 meta 声明 map(key 约定 = meta.cardType;数据 + 类型源) */
|
|
38
84
|
const cards = {
|
|
39
85
|
'member-info': memberInfoMeta,
|
|
40
86
|
'member-card-summary': memberCardSummaryMeta,
|
|
41
|
-
'member-coupon-list': memberCouponListMeta,
|
|
42
|
-
'member-coupon-detail': memberCouponDetailMeta,
|
|
43
87
|
'member-point-usage-list': memberPointUsageListMeta,
|
|
44
88
|
'member-enrollment-form': memberEnrollmentFormMeta,
|
|
45
89
|
'member-enrollment-status': memberEnrollmentStatusMeta,
|
|
90
|
+
'member-enrollment-cta': memberEnrollmentCtaMeta,
|
|
91
|
+
'member-login-cta': memberLoginCtaMeta,
|
|
46
92
|
} as const;
|
|
47
93
|
|
|
48
|
-
/** 入会写动作(011 E4:singleUse grant 闭环) */
|
|
49
|
-
const enrollAction = createEnrollAction(() => member);
|
|
50
|
-
|
|
51
94
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
95
|
+
* 会员前置工具暂时停用(保留原实现,便于恢复):
|
|
96
|
+
* queryPoints 会串联真实 MallCard/Get、BonusHistory 和卡片投影,当前前置预算下容易超时,
|
|
97
|
+
* 导致会员 Skill 一直处于执行中。现阶段统一走下方业务工具 queryPoints/queryMemberInfo/queryMemberCard,
|
|
98
|
+
* 等真实接口超时与前置预算重新评估后再恢复。
|
|
99
|
+
*
|
|
100
|
+
* const preTools = {
|
|
101
|
+
* queryPoints: definePreTool(queryPointsTool, {
|
|
102
|
+
* routing: { mode: 'always' },
|
|
103
|
+
* decide: ({ identity, text }) =>
|
|
104
|
+
* identity && identity.source !== 'anonymous'
|
|
105
|
+
* ? { decision: 'run', args: { userId: identity.userId, query: text } }
|
|
106
|
+
* : { decision: 'skip', reason: '匿名身份不预执行' },
|
|
107
|
+
* }),
|
|
108
|
+
* } satisfies Record<string, PreToolDef>;
|
|
54
109
|
*/
|
|
55
|
-
const preTools = {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}),
|
|
63
|
-
} satisfies Record<string, PreToolDef>;
|
|
110
|
+
const preTools = {} as const;
|
|
111
|
+
|
|
112
|
+
/** 文件加载式 skill(009 两形态之二;promotion 先例):顶层 await → 填回声明式通道(装配端 registerAll 自动注册)。
|
|
113
|
+
* codegen 展开改写约定:第一参随包名改写(@tbox.cn/app-module-member → @app/module-member)。 */
|
|
114
|
+
const skills = Object.fromEntries(
|
|
115
|
+
(await loadModuleSkills('@tbox.cn/app-module-member', import.meta.url)).map((s) => [s.name, s]),
|
|
116
|
+
);
|
|
64
117
|
|
|
65
118
|
/** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
|
|
66
119
|
export const serverModule = {
|
|
67
120
|
declaration: {
|
|
68
|
-
|
|
69
|
-
features: ['member-profile', 'member-card', 'member-benefits', 'member-points', 'member-coupons', 'member-enrollment'],
|
|
121
|
+
moduleId: 'member',
|
|
70
122
|
},
|
|
71
123
|
cards,
|
|
72
124
|
preTools,
|
|
125
|
+
skills,
|
|
73
126
|
register(ctx: ServerContext): void {
|
|
74
|
-
ctx
|
|
127
|
+
serverCtx = ctx;
|
|
128
|
+
// 上下文统一化:'member.service' 解析工厂(跨模块消费方 resolveService 后
|
|
129
|
+
// await factory(reqCtx)——替代旧 'member' 全局门面,并发安全)。
|
|
130
|
+
// 017 槽位原子化:积分结算写面语义锚 = benefits 槽(跨模块消费方为积分域)
|
|
131
|
+
ctx.services.register('member.service', (reqCtx: RequestContext) =>
|
|
132
|
+
resolveMemberBenefitsPort(reqCtx, ctx),
|
|
133
|
+
);
|
|
134
|
+
// 会员状态解析工厂(member.status):跨模块门禁消费(如领券 acquire);
|
|
135
|
+
// soft 单档——缺席/失败收口 unavailable,不伪装状态
|
|
136
|
+
ctx.services.register('member.status', (reqCtx: RequestContext) =>
|
|
137
|
+
resolveMemberStatus(reqCtx, ctx),
|
|
138
|
+
);
|
|
75
139
|
ctx.tools.register(queryPointsTool);
|
|
76
140
|
ctx.tools.register(queryMemberInfoTool);
|
|
77
141
|
ctx.tools.register(queryMemberCardTool);
|
|
78
|
-
ctx.tools.register(queryMemberCouponsTool);
|
|
79
142
|
ctx.tools.register(queryEnrollmentFormTool);
|
|
143
|
+
ctx.tools.register(queryEnrollmentCtaTool);
|
|
144
|
+
ctx.tools.register(queryLoginCtaTool);
|
|
80
145
|
ctx.cards.registerMap(cards);
|
|
81
146
|
ctx.handlers.register(createMemberHandler(ctx));
|
|
82
|
-
// 011 E7:入会写动作声明(装配端 dispatcher 收集 + 出卡时签发 token)
|
|
83
|
-
ctx.cardActions.register(enrollAction);
|
|
84
147
|
|
|
85
|
-
//
|
|
86
|
-
const
|
|
148
|
+
// 015 S4:入会四动作(register 闭包 ctx——动作 execute 内经 enrollment 槽解析 + ctx.state PII 暂存)
|
|
149
|
+
const actions = createMemberActions(ctx, (reqCtx) => resolveMemberEnrollmentPort(reqCtx, ctx));
|
|
150
|
+
for (const action of Object.values(actions)) {
|
|
151
|
+
ctx.cardActions.register(action);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 015 D9:主体绑定(promotion acquire 消费;ctx.services 跨模块 resolveService)
|
|
155
|
+
ctx.services.register('member.subject-binding', createSubjectBinding(ctx));
|
|
87
156
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
157
|
+
// 会员快览工厂(member.snapshot,W3-3):首页联单面板跨模块消费面
|
|
158
|
+
// (账户/授权三态 + 积分 + 入会引导)。三槽组装——enrollment 槽 fail-soft
|
|
159
|
+
// (缺席不拖垮账户/积分主面);工厂运行期执行(注册期纯净铁律)。
|
|
160
|
+
ctx.services.register(
|
|
161
|
+
MEMBER_SNAPSHOT_SERVICE,
|
|
162
|
+
async (reqCtx: RequestContext): Promise<MemberSnapshotPort> => {
|
|
163
|
+
const [account, benefits, enrollment] = await Promise.all([
|
|
164
|
+
resolveMemberAccountPort(reqCtx, ctx),
|
|
165
|
+
resolveMemberBenefitsPort(reqCtx, ctx),
|
|
166
|
+
resolveMemberEnrollmentPort(reqCtx, ctx).catch(() => null),
|
|
167
|
+
]);
|
|
168
|
+
return {
|
|
169
|
+
getAccount: (userId: string) => account.getAccount(userId),
|
|
170
|
+
queryPoints: (userId: string) => benefits.queryPoints(userId),
|
|
171
|
+
...(enrollment?.enrollUrl ? { enrollUrl: enrollment.enrollUrl } : {}),
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
);
|
|
92
175
|
|
|
93
|
-
// 等级变更对外发布(member 发事件,其他模块订阅)
|
|
94
|
-
member.onLevelChange(({ userId, newLevel }) => {
|
|
95
|
-
void mallBus.publish('member.level.changed', { userId, newLevel });
|
|
96
|
-
});
|
|
97
176
|
},
|
|
98
177
|
} satisfies ServerModule;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* member Port 分槽解析(017 槽位原子化):五槽独立解析(account/profile/card/
|
|
3
|
+
* benefits/enrollment),无运行时 InMemory 兜底(InMemoryMemberService 仅限隔离单测)。
|
|
4
|
+
* 解析失败一律上抛:未配置/禁用等预期缺席错误码由 registry 抛出,调用方按业务文案转译
|
|
5
|
+
* (status fail-soft / 工具层文案兜底)。
|
|
6
|
+
*
|
|
7
|
+
* 非 Port 成员归位:loginUrl 挂 account 槽产物、enrollUrl 挂 enrollment 槽产物
|
|
8
|
+
* (厂商 fat 实现运行期携带;契约 Port 不含——返回类型上的可选属性扩展即类型视图)。
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
MEMBER_ACCOUNT_SERVICE,
|
|
12
|
+
MEMBER_BENEFITS_SERVICE,
|
|
13
|
+
MEMBER_CARD_SERVICE,
|
|
14
|
+
MEMBER_ENROLLMENT_SERVICE,
|
|
15
|
+
MEMBER_PROFILE_SERVICE,
|
|
16
|
+
type MemberAccountPort,
|
|
17
|
+
type MemberBenefitsPort,
|
|
18
|
+
type MemberCardPort,
|
|
19
|
+
type MemberEnrollmentPort,
|
|
20
|
+
type MemberProfilePort,
|
|
21
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
22
|
+
import { mallScopeOf } from '@tbox.cn/app-contracts-mall';
|
|
23
|
+
import type { RequestContext } from '@tbox.cn/app-contracts';
|
|
24
|
+
import type { ServerContext } from '@tbox.cn/app-sdk/server';
|
|
25
|
+
|
|
26
|
+
/** account 槽产物类型视图:Port + 厂商实例携带的登录深链(可选) */
|
|
27
|
+
export type MemberAccountService = MemberAccountPort & { readonly loginUrl?: string };
|
|
28
|
+
/** enrollment 槽产物类型视图:Port + 厂商实例携带的入会深链(可选) */
|
|
29
|
+
export type MemberEnrollmentService = MemberEnrollmentPort & { readonly enrollUrl?: string };
|
|
30
|
+
|
|
31
|
+
async function resolveMemberSlot<T>(
|
|
32
|
+
service: string,
|
|
33
|
+
reqCtx: RequestContext,
|
|
34
|
+
ctx: ServerContext,
|
|
35
|
+
): Promise<T> {
|
|
36
|
+
const scope = mallScopeOf(reqCtx);
|
|
37
|
+
if (!scope.mallId) throw new Error('会员 C 端请求缺少 mallId');
|
|
38
|
+
if (!ctx.integrations) throw new Error('integrations registry 缺失:member 服务不可用');
|
|
39
|
+
return (await ctx.integrations.resolveService({
|
|
40
|
+
service,
|
|
41
|
+
scope,
|
|
42
|
+
request: reqCtx,
|
|
43
|
+
})) as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function resolveMemberAccountPort(reqCtx: RequestContext, ctx: ServerContext): Promise<MemberAccountService> {
|
|
47
|
+
return resolveMemberSlot<MemberAccountService>(MEMBER_ACCOUNT_SERVICE, reqCtx, ctx);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resolveMemberProfilePort(reqCtx: RequestContext, ctx: ServerContext): Promise<MemberProfilePort> {
|
|
51
|
+
return resolveMemberSlot<MemberProfilePort>(MEMBER_PROFILE_SERVICE, reqCtx, ctx);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function resolveMemberCardPort(reqCtx: RequestContext, ctx: ServerContext): Promise<MemberCardPort> {
|
|
55
|
+
return resolveMemberSlot<MemberCardPort>(MEMBER_CARD_SERVICE, reqCtx, ctx);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function resolveMemberBenefitsPort(reqCtx: RequestContext, ctx: ServerContext): Promise<MemberBenefitsPort> {
|
|
59
|
+
return resolveMemberSlot<MemberBenefitsPort>(MEMBER_BENEFITS_SERVICE, reqCtx, ctx);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function resolveMemberEnrollmentPort(reqCtx: RequestContext, ctx: ServerContext): Promise<MemberEnrollmentService> {
|
|
63
|
+
return resolveMemberSlot<MemberEnrollmentService>(MEMBER_ENROLLMENT_SERVICE, reqCtx, ctx);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { InMemoryMemberService } from './service';
|