@tbox.cn/app-module-member 0.2.0 → 0.8.6

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.
Files changed (65) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +99 -11
  3. package/dist/chunk-KEBILLQ5.js +771 -0
  4. package/dist/chunk-T4SYPQA5.js +1565 -0
  5. package/dist/client/index.d.ts +2 -2
  6. package/dist/client/index.js +1 -1
  7. package/dist/index.d.ts +2 -3
  8. package/dist/index.js +12 -2
  9. package/dist/login-mascot-5D4X6YZ7.jpg +0 -0
  10. package/dist/member-QFCIYILN.css +1734 -0
  11. package/dist/server/index.d.ts +617 -246
  12. package/dist/server/index.js +11 -1
  13. package/package.json +25 -16
  14. package/skills/member/SKILL.md +141 -0
  15. package/skills/member/references/enrollment.md +34 -0
  16. package/skills/member/references/points-and-card.md +24 -0
  17. package/src/client/assets/login-mascot.jpg +0 -0
  18. package/src/client/assets.d.ts +5 -0
  19. package/src/client/cards/index.ts +10 -0
  20. package/src/client/cards/member-card-summary/index.tsx +375 -0
  21. package/src/client/cards/member-enrollment-cta/index.tsx +61 -0
  22. package/src/client/cards/member-enrollment-form/index.tsx +181 -0
  23. package/src/client/cards/member-enrollment-status/index.tsx +65 -0
  24. package/src/client/cards/member-info/index.tsx +60 -0
  25. package/src/client/cards/member-login-cta/index.tsx +81 -0
  26. package/src/client/cards/member-point-usage-list/index.tsx +53 -0
  27. package/src/client/cards/view.ts +39 -0
  28. package/src/client/components/ImageWithFallback.tsx +38 -0
  29. package/src/client/index.ts +6 -4
  30. package/src/client/styles/member.css +1734 -0
  31. package/src/client/types/css.d.ts +2 -0
  32. package/src/server/action.ts +348 -0
  33. package/src/server/cards/index.ts +7 -7
  34. package/src/server/cards/member-card-summary/meta.ts +26 -5
  35. package/src/server/cards/member-card-summary/samples.ts +272 -0
  36. package/src/server/cards/member-enrollment-cta/meta.ts +19 -0
  37. package/src/server/cards/member-enrollment-form/meta.ts +10 -6
  38. package/src/server/cards/member-enrollment-status/meta.ts +5 -6
  39. package/src/server/cards/member-info/meta.ts +1 -1
  40. package/src/server/cards/member-login-cta/meta.ts +16 -0
  41. package/src/server/cards/member-point-usage-list/meta.ts +2 -3
  42. package/src/server/handler.ts +159 -119
  43. package/src/server/index.ts +109 -50
  44. package/src/server/ports.ts +66 -0
  45. package/src/server/service.ts +94 -80
  46. package/src/server/status.ts +87 -0
  47. package/src/server/subject-binding.ts +19 -0
  48. package/src/server/tool.ts +469 -99
  49. package/tbox.module.json +124 -0
  50. package/tests/cards-render.test.tsx +526 -0
  51. package/tests/enrollment.test.ts +121 -0
  52. package/tests/ports-resolve.test.ts +95 -0
  53. package/tests/preference-memory.test.ts +86 -0
  54. package/tests/samples.test.ts +61 -0
  55. package/tests/service.test.ts +593 -35
  56. package/tests/skills.test.ts +65 -0
  57. package/tests/status.test.ts +127 -0
  58. package/tests/view.test.ts +33 -0
  59. package/tsup.config.ts +9 -1
  60. package/dist/chunk-JGZGLFO6.js +0 -111
  61. package/dist/chunk-ZV6B36AO.js +0 -613
  62. package/src/client/cards/index.tsx +0 -135
  63. package/src/server/cards/member-coupon-detail/meta.ts +0 -19
  64. package/src/server/cards/member-coupon-list/meta.ts +0 -18
  65. package/tbox.component.json +0 -28
@@ -1,71 +1,269 @@
1
- import { createTool, coerceStringifiedFields } from '@tbox.cn/app-sdk/server';
1
+ import { createTool } from '@tbox.cn/app-sdk/server';
2
+ import type { AnyTool, ToolExecContextLike } from '@tbox.cn/app-sdk/server';
3
+ import { requireToolRequestContext } from '@tbox.cn/app-sdk/server';
4
+ import type { RequestContext } from '@tbox.cn/app-contracts';
5
+ import { MEMBER_PREFERENCE_PROMPTS } from '@tbox.cn/app-contracts-mall';
6
+ import type { MemberAccountSnapshot, MemberCardPort } from '@tbox.cn/app-contracts-mall';
7
+ import { classifyMemberOutcome, memberGateCardOf } from '@tbox.cn/app-contracts-mall';
8
+ import {
9
+ MEMBER_LOGIN_NOTICE,
10
+ MEMBER_NOT_ENROLLED_NOTICE,
11
+ MEMBER_NO_POINTS_NOTICE,
12
+ MEMBER_UNKNOWN_NOTICE,
13
+ } from './status';
2
14
  import { z } from 'zod';
3
- import type { MemberBenefitsPort, MemberAccountPort, MemberCardPort, MemberCouponPort } from '@tbox.cn/app-contracts-mall';
4
- import { nowIso } from './util';
5
15
 
6
- function coerce<T extends z.ZodTypeAny>(schema: T): T {
7
- return z.preprocess(coerceStringifiedFields, schema) as unknown as T;
16
+ /** 出卡出参组装(v2 契约):顶层答话事实 + cards 声明(data = 事实本体,不含 cards 键) */
17
+ function withCards<T extends Record<string, unknown>>(facts: T, cardType: string, note: string): T & { cards: unknown[] } {
18
+ return { ...facts, cards: [{ cardType, data: facts, note }] };
8
19
  }
9
20
 
10
- /** 积分查询工具(011 K3:出卡 member-point-usage-list——吸收教学 points 卡) */
11
- export function createQueryPointsTool(getMember: () => MemberBenefitsPort | undefined) {
21
+ /** 会员账户态中文化(note 契约——note 是模型可见摘要,英文枚举态不可读) */
22
+ export function memberStateLabel(state: string): string {
23
+ switch (state) {
24
+ case 'active':
25
+ return '已入会';
26
+ case 'not_enrolled':
27
+ return '未入会';
28
+ case 'closed':
29
+ return '已退会';
30
+ case 'unauthenticated':
31
+ return '未登录';
32
+ default:
33
+ return '状态确认中';
34
+ }
35
+ }
36
+
37
+ const MEMBER_CONTRACT_NOTICE =
38
+ '会员卡名不是会员等级;当前查询未返回会员权益,不得推断折扣、停车、生日或活动权益';
39
+
40
+ export const MEMBER_PRIVACY_REFUSAL =
41
+ '出于会员隐私保护,我只能查询当前登录会员的信息,不能修改会员资料、代查他人账户,也不能通过手机号或会员卡号切换账户。';
42
+
43
+ /**
44
+ * Code security: 查询语境中的外部账号标识只能用于拒答,绝不能成为会员资源定位条件。
45
+ * 保守拦截代查、切号和资料修改请求,正常的“我的会员/我的积分”查询不受影响。
46
+ */
47
+ export function memberPrivacyRefusal(query: unknown): string | undefined {
48
+ if (typeof query !== 'string' || query.trim().length === 0) return undefined;
49
+ const content = query.replace(/\s+/g, '');
50
+ const profileMutation =
51
+ /(手机号|手机号码|联系方式).{0,12}(改|修改|更换|变更|替换|更新)|(改|修改|更换|变更|替换|更新).{0,12}(手机号|手机号码|联系方式)/.test(content);
52
+ const thirdPartyLookup =
53
+ /(老婆|老公|妻子|丈夫|他人|别人|朋友|同事|家人|孩子|女儿|儿子|妈妈|爸爸).{0,20}(手机号|手机号码|会员|积分|卡)/.test(content);
54
+ const suppliedIdentifierLookup =
55
+ /(用|通过|根据|拿着|拿).{0,20}(手机号|手机号码|会员卡号|卡号).{0,20}(查|查询|切|切换|登录|绑定)/.test(content) ||
56
+ /(切换|切到|换到|登录到|绑定到).{0,15}(账号|账户|会员卡|卡号|手机号|手机号码)/.test(content);
57
+ const explicitIdentifier = /(手机号|手机号码|会员卡号|卡号|账号|账户).{0,16}\d{5,}/.test(content);
58
+ return profileMutation || thirdPartyLookup || suppliedIdentifierLookup || explicitIdentifier
59
+ ? MEMBER_PRIVACY_REFUSAL
60
+ : undefined;
61
+ }
62
+
63
+ function hasCompleteMemberCard(card: MemberCardSnapshot | null): boolean {
64
+ return card?.presentation !== undefined;
65
+ }
66
+
67
+ function cardContractNotice(card: { benefits?: readonly unknown[] }): string {
68
+ return card.benefits && card.benefits.length > 0
69
+ ? '会员卡名不是会员等级;仅展示系统返回的当前等级权益,不得推断额外折扣或服务'
70
+ : MEMBER_CONTRACT_NOTICE;
71
+ }
72
+
73
+ type MemberCardSnapshot = NonNullable<Awaited<ReturnType<MemberCardPort['getCard']>>>;
74
+
75
+ function buildMemberCardFacts(
76
+ userId: string,
77
+ card: MemberCardSnapshot,
78
+ availablePoints: number,
79
+ includePreferencePrompts = false,
80
+ ) {
81
+ return {
82
+ userId,
83
+ cardRef: card.cardRef,
84
+ cardDisplay: card.cardDisplay,
85
+ status: card.status,
86
+ ...(card.levelName ? { levelName: card.levelName } : {}),
87
+ availablePoints,
88
+ ...(card.cardImageUrl ? { cardImageUrl: card.cardImageUrl } : {}),
89
+ ...(card.pointsRuleImageUrl ? { pointsRuleImageUrl: card.pointsRuleImageUrl } : {}),
90
+ ...(card.pointsRuleContent ? { pointsRuleContent: card.pointsRuleContent } : {}),
91
+ ...(card.pointsUrl ? { pointsUrl: card.pointsUrl } : {}),
92
+ ...(card.presentation ? { presentation: card.presentation } : {}),
93
+ ...(card.progress ? { progress: card.progress } : {}),
94
+ ...(card.benefits ? { benefits: [...card.benefits] } : {}),
95
+ ...(includePreferencePrompts ? { preferencePrompts: [...MEMBER_PREFERENCE_PROMPTS] } : {}),
96
+ contractNotice: cardContractNotice(card),
97
+ };
98
+ }
99
+
100
+ function memberCardCandidate(facts: ReturnType<typeof buildMemberCardFacts>) {
101
+ return {
102
+ cardType: 'member-card-summary',
103
+ data: facts,
104
+ note: `会员卡(可用积分 ${facts.availablePoints})`,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * 匿名短路(上下文统一化 Stage 3):userId 来源 = 运行身份(reqCtx.identity.userId),
110
+ * 匿名身份无会员语义——统一「请先登录」分支(不再依赖外部门控/模型传参)。
111
+ */
112
+ function anonymousShort(userId: string, action: string): Record<string, unknown> {
113
+ return {
114
+ userId,
115
+ requiresAuth: true,
116
+ found: false,
117
+ noResultsReason: `请先登录后再${action}`,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * 已登录未入会引导卡(三查询工具 not_enrolled/closed 分支共用):`outcome` 命中硬门禁态即出
123
+ * `member-enrollment-cta`(mall 模板投影为半屏入会闭环;base 模板内联渲染),深链为可选
124
+ * 透传素材(service.enrollUrl/loginUrl 有则带,未配置恒出卡、无 URL 字段)。
125
+ * 登录 ≠ 入会——unauthenticated 不出此卡。
126
+ */
127
+ async function maybeEnrollmentCta(
128
+ reqCtx: RequestContext,
129
+ account: MemberAccountSnapshot,
130
+ links: { enrollUrl?: string; loginUrl?: string },
131
+ ): Promise<{ cardType: string; data: Record<string, unknown>; note: string } | undefined> {
132
+ const outcome = classifyMemberOutcome(reqCtx.identity, account);
133
+ if (outcome !== 'not_enrolled' && outcome !== 'closed') return undefined; // 含 unauthenticated/unknown/active 软放行
134
+ return memberGateCardOf(reqCtx.identity.userId, {
135
+ outcome,
136
+ ...(links.enrollUrl ? { enrollUrl: links.enrollUrl } : {}),
137
+ ...(links.loginUrl ? { loginUrl: links.loginUrl } : {}),
138
+ });
139
+ }
140
+
141
+ /** 非 active 查询话术(not_enrolled/closed/unknown 拆分;出卡与深链解耦后不再依赖 URL 配置)。 */
142
+ function queryNoResultsNotice(account: { authState: string; state: string }): string {
143
+ if (account.authState === 'unauthenticated') return MEMBER_LOGIN_NOTICE;
144
+ if (account.state === 'not_enrolled') return MEMBER_NOT_ENROLLED_NOTICE;
145
+ if (account.state === 'closed') return MEMBER_NO_POINTS_NOTICE;
146
+ return MEMBER_UNKNOWN_NOTICE;
147
+ }
148
+
149
+ /** 已登录未入会/已关闭非查询话术(not_enrolled/closed 拆分——入会引导 ≠ 已退会无积分)。 */
150
+ function queryNonActiveNotice(account: { authState: string; state: string }): string {
151
+ return account.state === 'closed' ? MEMBER_NO_POINTS_NOTICE : MEMBER_NOT_ENROLLED_NOTICE;
152
+ }
153
+
154
+
155
+ /** member 工具槽位注入(017 槽位原子化):各工具按需取槽,替换旧 fat getMember。
156
+ * loginUrl 挂 account 产物、enrollUrl/表单挂 enrollment 产物(厂商实例携带)。 */
157
+ export interface MemberToolPorts {
158
+ getAccount: (reqCtx: RequestContext) => Promise<import('./ports').MemberAccountService>;
159
+ getProfile: (reqCtx: RequestContext) => Promise<import('@tbox.cn/app-contracts-mall').MemberProfilePort>;
160
+ getCard: (reqCtx: RequestContext) => Promise<import('@tbox.cn/app-contracts-mall').MemberCardPort>;
161
+ getBenefits: (reqCtx: RequestContext) => Promise<import('@tbox.cn/app-contracts-mall').MemberBenefitsPort>;
162
+ getEnrollment: (reqCtx: RequestContext) => Promise<import('./ports').MemberEnrollmentService>;
163
+ }
164
+
165
+ /** 积分查询工具:积分流水仅作为 LLM 文本事实,工具候选只出会员卡面。 */
166
+ export function createQueryPointsTool(
167
+ ports: MemberToolPorts,
168
+ ): AnyTool {
12
169
  return createTool({
13
170
  id: 'queryPoints',
14
- description: '查询会员当前积分余额、等级与积分流水',
171
+ description:
172
+ '查询会员当前积分余额、会员卡面与积分流水;返回的 balance 是当前积分点数,items 中 title 是流水业务说明、amount 是本笔变动(正数增加、负数扣减)、usedAt 是发生时间、description 是可选门店名称;流水仅供模型组织文本,不构造积分流水卡。已登录未入会时返回入会引导卡(member-enrollment-cta),无需再调 queryEnrollmentCta。',
15
173
  inputSchema: z.object({
16
- userId: z.string().describe('会员 userId'),
17
- }),
18
- outputSchema: z.object({
19
- userId: z.string(),
20
- balance: z.number(),
21
- level: z.enum(['silver', 'gold', 'platinum']),
22
- items: z.array(
23
- z.object({
24
- usageRecordRef: z.string(),
25
- title: z.string(),
26
- amount: z.string(),
27
- usedAt: z.string().optional(),
28
- description: z.string().optional(),
29
- }),
30
- ),
31
- total: z.number(),
174
+ query: z.string().optional().describe('当前用户原始问题;用于保留查询语境,不参与身份或资源定位'),
32
175
  }),
33
- execute: async ({ userId }: { userId: string }) => {
34
- const member = getMember();
35
- const points = member
36
- ? await member.queryPoints(userId)
37
- : { userId, balance: 0, level: 'silver' as const };
38
- const usage = member ? await member.listPointUsage(userId, 1, 10) : { items: [], total: 0 };
39
- return {
176
+ execute: async (input: { query?: string }, execCtx: ToolExecContextLike) => {
177
+ const reqCtx = requireToolRequestContext(execCtx, 'queryPoints');
178
+ const privacyRefusal = memberPrivacyRefusal(input.query);
179
+ if (privacyRefusal) return { found: false, noResultsReason: privacyRefusal };
180
+ const userId = reqCtx.identity.userId;
181
+ if (reqCtx.identity.source === 'anonymous') {
182
+ return anonymousShort(userId, '查询会员积分');
183
+ }
184
+ const [accountPort, cardPort, benefitsPort, enrollmentPort] = await Promise.all([ports.getAccount(reqCtx), ports.getCard(reqCtx), ports.getBenefits(reqCtx), ports.getEnrollment(reqCtx)]);
185
+ const account = await accountPort.getAccount(userId);
186
+ if (account.authState === 'unauthenticated' || account.state !== 'active') {
187
+ const enrollCard = await maybeEnrollmentCta(reqCtx, account, { enrollUrl: enrollmentPort.enrollUrl, loginUrl: accountPort.loginUrl });
188
+ if (enrollCard) {
189
+ return {
190
+ userId,
191
+ found: false,
192
+ authState: account.authState,
193
+ state: account.state,
194
+ noResultsReason: queryNonActiveNotice(account),
195
+ cards: [enrollCard],
196
+ };
197
+ }
198
+ return {
199
+ userId,
200
+ found: false,
201
+ authState: account.authState,
202
+ state: account.state,
203
+ noResultsReason: queryNoResultsNotice(account),
204
+ };
205
+ }
206
+ const card = await cardPort.getCard(userId);
207
+ let points = { userId, balance: 0 } as Awaited<ReturnType<typeof benefitsPort.queryPoints>>;
208
+ let pointsUnavailable = false;
209
+ try {
210
+ points = await benefitsPort.queryPoints(userId);
211
+ } catch {
212
+ pointsUnavailable = true;
213
+ }
214
+ const completeCard = hasCompleteMemberCard(card);
215
+ // A complete visual card may still expose a native points detail page.
216
+ // Providers with that capability supply records as tool facts for the text answer.
217
+ let usage: { items: ReadonlyArray<unknown>; total: number } = { items: [], total: 0 };
218
+ let usageUnavailable = false;
219
+ if (!completeCard || card?.pointsUrl) {
220
+ try {
221
+ usage = await benefitsPort.listPointUsage(userId, 1, 10);
222
+ } catch {
223
+ usageUnavailable = true;
224
+ }
225
+ }
226
+ const facts = {
40
227
  userId,
41
228
  balance: points.balance,
42
- level: points.level,
229
+ ...(points.membershipLabel ? { membershipLabel: points.membershipLabel } : {}),
43
230
  items: [...usage.items],
44
231
  total: usage.total,
232
+ contractNotice: MEMBER_CONTRACT_NOTICE,
233
+ ...(pointsUnavailable ? { pointsNotice: '当前积分余额暂不可用,会员卡仍可正常查看' } : {}),
234
+ ...(usageUnavailable ? { pointsHistoryNotice: '暂无最近积分流水记录' } : {}),
45
235
  };
236
+ if (!card) return { ...facts, cards: [] };
237
+
238
+ const memberCard = memberCardCandidate(buildMemberCardFacts(userId, card, points.balance, true));
239
+ return { ...facts, cards: [memberCard] };
46
240
  },
47
241
  });
48
242
  }
49
243
 
50
- /** 会员信息查询 member-info */
244
+ /** 明确会员资料查询:完整卡面 provider 只出主卡,基础卡面 provider 追加资料卡。 */
51
245
  export function createQueryMemberInfoTool(
52
- getMember: () => (MemberAccountPort & { getProfile(userId: string): Promise<unknown> }) | undefined,
53
- ) {
246
+ ports: MemberToolPorts,
247
+ ): AnyTool {
54
248
  return createTool({
55
249
  id: 'queryMemberInfo',
56
- description: '查询会员账户状态与个人资料(是否入会、昵称、联系方式、标签)',
57
- inputSchema: coerce(z.object({ userId: z.string().describe('会员 userId') })),
58
- execute: async ({ userId }: { userId: string }) => {
59
- const member = getMember();
60
- const account = member ? await member.getAccount(userId) : { state: 'not_enrolled' as const, observedAt: nowIso() };
61
- const profile = (member ? await member.getProfile(userId) : { labels: [] }) as {
62
- displayName?: string;
63
- avatarUrl?: string;
64
- contactDisplay?: string;
65
- labels: { labelRef: string; name: string }[];
66
- };
67
- return {
250
+ description:
251
+ '查询明确的会员账户资料(是否入会、昵称、联系方式、标签);传入当前用户原始问题作为 query(不用于身份定位);已登录未入会时附带入会引导卡(member-enrollment-cta),无需再调 queryEnrollmentCta。',
252
+ inputSchema: z.object({ query: z.string().optional().describe('当前用户原始问题,不用于身份定位') }),
253
+ execute: async (input: { query?: string }, execCtx: ToolExecContextLike) => {
254
+ const reqCtx = requireToolRequestContext(execCtx, 'queryMemberInfo');
255
+ const privacyRefusal = memberPrivacyRefusal(input.query);
256
+ if (privacyRefusal) return { found: false, noResultsReason: privacyRefusal };
257
+ const userId = reqCtx.identity.userId;
258
+ if (reqCtx.identity.source === 'anonymous') {
259
+ return anonymousShort(userId, '查询会员信息');
260
+ }
261
+ const [accountPort, cardPort, benefitsPort, profilePort, enrollmentPort] = await Promise.all([ports.getAccount(reqCtx), ports.getCard(reqCtx), ports.getBenefits(reqCtx), ports.getProfile(reqCtx), ports.getEnrollment(reqCtx)]);
262
+ const account = await accountPort.getAccount(userId);
263
+ const profile = await profilePort.getProfile(userId);
264
+ const facts = {
68
265
  userId,
266
+ authState: account.authState,
69
267
  state: account.state,
70
268
  ...(profile.displayName ? { displayName: profile.displayName } : {}),
71
269
  ...(profile.avatarUrl ? { avatarUrl: profile.avatarUrl } : {}),
@@ -73,6 +271,31 @@ export function createQueryMemberInfoTool(
73
271
  ...(account.memberRef ? { memberRef: account.memberRef } : {}),
74
272
  ...(account.registeredAt ? { registeredAt: account.registeredAt } : {}),
75
273
  labels: profile.labels ?? [],
274
+ contractNotice: MEMBER_CONTRACT_NOTICE,
275
+ };
276
+ const infoCard = {
277
+ cardType: 'member-info',
278
+ data: facts,
279
+ // note 契约(W7):账户态中文化 + 姓名要点
280
+ note: `会员账户(${memberStateLabel(facts.state)})${facts.displayName ? ` · ${facts.displayName}` : ''}`,
281
+ };
282
+ if (account.state !== 'active') {
283
+ // 已登录未入会:入会引导卡排首位(mall 模板投影半屏闭环),资料卡保留展示账户状态
284
+ const enrollCard = await maybeEnrollmentCta(reqCtx, account, { enrollUrl: enrollmentPort.enrollUrl, loginUrl: accountPort.loginUrl });
285
+ if (enrollCard) return { ...facts, cards: [enrollCard, infoCard] };
286
+ return { ...facts, cards: [infoCard] };
287
+ }
288
+
289
+ const [card, points] = await Promise.all([
290
+ cardPort.getCard(userId),
291
+ benefitsPort.queryPoints(userId),
292
+ ]);
293
+ if (!card) return { ...facts, cards: [infoCard] };
294
+ const memberCard = memberCardCandidate(buildMemberCardFacts(userId, card, points.balance));
295
+ if (hasCompleteMemberCard(card)) return { ...facts, cards: [memberCard] };
296
+ return {
297
+ ...facts,
298
+ cards: [memberCard, infoCard],
76
299
  };
77
300
  },
78
301
  });
@@ -80,77 +303,224 @@ export function createQueryMemberInfoTool(
80
303
 
81
304
  /** 会员卡查询 → member-card-summary 卡 */
82
305
  export function createQueryMemberCardTool(
83
- getMember: () => (MemberCardPort & MemberBenefitsPort) | undefined,
84
- ) {
306
+ ports: MemberToolPorts,
307
+ ): AnyTool {
85
308
  return createTool({
86
309
  id: 'queryMemberCard',
87
- description: '查询会员卡信息(卡面、等级、积分摘要)',
88
- inputSchema: coerce(z.object({ userId: z.string().describe('会员 userId') })),
89
- execute: async ({ userId }: { userId: string }) => {
90
- const member = getMember();
91
- const card = member ? await member.getCard(userId) : null;
92
- const points = member ? await member.queryPoints(userId) : { balance: 0, level: 'silver' as const };
93
- return {
94
- userId,
95
- cardRef: card?.cardRef ?? `card:${userId}`,
96
- cardDisplay: card?.cardDisplay ?? '大悦城会员卡',
97
- status: card?.status ?? 'unknown',
98
- ...(card?.levelName ? { levelName: card.levelName } : { levelName: points.level }),
99
- availablePoints: points.balance,
100
- ...(card?.cardImageUrl ? { cardImageUrl: card.cardImageUrl } : {}),
310
+ description:
311
+ '查询会员卡信息(卡面、卡名、积分摘要);传入当前用户原始问题作为 query(不用于身份定位);已登录未入会时返回入会引导卡(member-enrollment-cta),无需再调 queryEnrollmentCta。',
312
+ inputSchema: z.object({ query: z.string().optional().describe('当前用户原始问题,不用于身份定位') }),
313
+ execute: async (input: { query?: string }, execCtx: ToolExecContextLike) => {
314
+ const reqCtx = requireToolRequestContext(execCtx, 'queryMemberCard');
315
+ const privacyRefusal = memberPrivacyRefusal(input.query);
316
+ if (privacyRefusal) return { found: false, noResultsReason: privacyRefusal };
317
+ const userId = reqCtx.identity.userId;
318
+ if (reqCtx.identity.source === 'anonymous') {
319
+ return anonymousShort(userId, '查询会员卡');
320
+ }
321
+ const [accountPort, cardPort, benefitsPort, enrollmentPort] = await Promise.all([ports.getAccount(reqCtx), ports.getCard(reqCtx), ports.getBenefits(reqCtx), ports.getEnrollment(reqCtx)]);
322
+ const account = await accountPort.getAccount(userId);
323
+ if (account.authState === 'unauthenticated' || account.state === 'unknown') {
324
+ return {
325
+ userId,
326
+ found: false,
327
+ authState: account.authState,
328
+ state: account.state,
329
+ noResultsReason:
330
+ account.authState === 'unauthenticated'
331
+ ? MEMBER_LOGIN_NOTICE
332
+ : MEMBER_UNKNOWN_NOTICE,
333
+ };
334
+ }
335
+ if (account.state !== 'active') {
336
+ const enrollCard = await maybeEnrollmentCta(reqCtx, account, { enrollUrl: enrollmentPort.enrollUrl, loginUrl: accountPort.loginUrl });
337
+ if (enrollCard) {
338
+ return {
339
+ userId,
340
+ found: false,
341
+ authState: account.authState,
342
+ state: account.state,
343
+ noResultsReason: queryNonActiveNotice(account),
344
+ cards: [enrollCard],
345
+ };
346
+ }
347
+ return {
348
+ userId,
349
+ found: false,
350
+ authState: account.authState,
351
+ state: account.state,
352
+ noResultsReason: '当前用户尚未在该商场开通会员卡',
353
+ };
354
+ }
355
+ const card = await cardPort.getCard(userId);
356
+ const points = await benefitsPort.queryPoints(userId);
357
+ if (!card) {
358
+ return {
359
+ userId,
360
+ found: false,
361
+ authState: account.authState,
362
+ state: 'unknown',
363
+ noResultsReason: '会员账户已激活,但暂未取得会员卡信息,请稍后重试',
364
+ };
365
+ }
366
+ const facts = buildMemberCardFacts(userId, card, points.balance);
367
+ return withCards(facts, 'member-card-summary', `会员卡(可用积分 ${points.balance})`);
368
+ },
369
+ });
370
+ }
371
+
372
+ /** 开卡入会引导工具 → member-enrollment-cta 卡(点击跳转 alipays 链接) */
373
+ export function createQueryEnrollmentCtaTool(
374
+ ports: MemberToolPorts,
375
+ ): AnyTool {
376
+ return createTool({
377
+ id: 'queryEnrollmentCta',
378
+ description:
379
+ '开卡入会引导:当用户询问如何成为会员、怎么开卡、入会流程、会员注册时调用,返回开卡入会引导卡(点击后跳转支付宝小程序完成入会)。' +
380
+ '【注意】已入会用户不出此卡,改为查询 queryMemberCard。',
381
+ inputSchema: z.object({}),
382
+ execute: async (_input: Record<string, unknown>, execCtx: ToolExecContextLike) => {
383
+ const reqCtx = requireToolRequestContext(execCtx, 'queryEnrollmentCta');
384
+ const userId = reqCtx.identity.userId;
385
+ const [accountPort, enrollmentPort] = await Promise.all([ports.getAccount(reqCtx), ports.getEnrollment(reqCtx)]);
386
+ const account = await accountPort.getAccount(userId);
387
+
388
+ // 已入会:不出 CTA 卡
389
+ if (account.state === 'active') {
390
+ return {
391
+ userId,
392
+ found: false,
393
+ noResultsReason: '您已是会员,无需重复开卡',
394
+ authState: account.authState,
395
+ state: 'active',
396
+ };
397
+ }
398
+
399
+ // 未登录或会员状态未知都不能被投影成“未入会”。登录本身不依赖开通会员。
400
+ if (account.authState === 'unauthenticated') {
401
+ // 未登录(可能同时 state=unknown):给出可点的登录入口,而不是让用户读完「请先登录」
402
+ // 还得自己找地方登录。深链为可选透传素材(accountPort.loginUrl 有则带);
403
+ // 未配置恒出卡(无 URL 字段,客户端禁用按钮)。
404
+ const loginFacts = {
405
+ title: '登录后继续',
406
+ ...(accountPort.loginUrl ? { loginUrl: accountPort.loginUrl } : {}),
407
+ };
408
+ return {
409
+ ...loginFacts,
410
+ found: true as const,
411
+ cards: [{ cardType: 'member-login-cta', data: loginFacts, note: '未登录 · 点击卡片登录' }],
412
+ };
413
+ }
414
+ // 已登录但状态未知(确认失败)不该误导成未登录——给出刷新提示而非登录卡。
415
+ if (account.state === 'unknown') {
416
+ return {
417
+ userId,
418
+ found: false,
419
+ authState: account.authState,
420
+ state: account.state,
421
+ noResultsReason: '当前无法确认会员状态,请刷新登录状态后重试',
422
+ };
423
+ }
424
+
425
+ // 只有“已登录 + 已确认未入会/已关闭”才展示入会入口。深链为可选透传素材(未配置恒出卡)。
426
+ const status = {
427
+ outcome: account.state as 'not_enrolled' | 'closed',
428
+ ...(enrollmentPort.enrollUrl ? { enrollUrl: enrollmentPort.enrollUrl } : {}),
429
+ ...(accountPort.loginUrl ? { loginUrl: accountPort.loginUrl } : {}),
101
430
  };
431
+ const card = memberGateCardOf(reqCtx.identity.userId, status);
432
+ if (!card) {
433
+ return {
434
+ userId,
435
+ found: false,
436
+ authState: account.authState,
437
+ state: account.state,
438
+ noResultsReason: '当前无法确认会员状态,请稍后重试',
439
+ };
440
+ }
441
+ const facts = { userId, ...card.data };
442
+ return withCards(facts, card.cardType, card.note);
102
443
  },
103
444
  });
104
445
  }
105
446
 
106
- /** 入会表单查询 → member-enrollment-form 卡(011 B3 触发链:出表单卡 → 按钮 → componentAction → dispatcher) */
107
447
  export function createQueryEnrollmentFormTool(
108
- getMember: () => (MemberAccountPort & MemberBenefitsPort) | undefined,
109
- ) {
448
+ ports: MemberToolPorts,
449
+ ): AnyTool {
110
450
  return createTool({
111
451
  id: 'queryEnrollmentForm',
112
- description: '查询会员入会状态并返回入会表单(未入会时用户可提交入会)',
113
- inputSchema: z.object({ userId: z.string().describe('会员 userId') }),
114
- execute: async ({ userId }: { userId: string }) => {
115
- const member = getMember();
116
- const account = member ? await member.getAccount(userId) : { state: 'not_enrolled' as const };
117
- return {
452
+ description: '查询会员入会状态并返回入会表单(含支付宝授权预填入口、隐私同意项;未入会时用户可授权或手动填写后提交)',
453
+ inputSchema: z.object({}),
454
+ execute: async (_input: Record<string, unknown>, execCtx: ToolExecContextLike) => {
455
+ const reqCtx = requireToolRequestContext(execCtx, 'queryEnrollmentForm');
456
+ const userId = reqCtx.identity.userId;
457
+ if (reqCtx.identity.source === 'anonymous') {
458
+ // 表单面向「已授权未入会」用户;匿名应走 queryEnrollmentCta(授权引导)而非表单
459
+ return anonymousShort(userId, '查询入会表单');
460
+ }
461
+ const [accountPort, enrollmentPort] = await Promise.all([ports.getAccount(reqCtx), ports.getEnrollment(reqCtx)]);
462
+ const account = await accountPort.getAccount(userId);
463
+ const notEnrolled =
464
+ account.authState === 'authenticated' &&
465
+ (account.state === 'not_enrolled' || account.state === 'closed');
466
+ // getEnrollmentFormDefinition = Port optional 能力口(R13 收编):无表单端点厂商可缺省,typeof 守卫降级
467
+ const definition =
468
+ notEnrolled && typeof enrollmentPort.getEnrollmentFormDefinition === 'function'
469
+ ? await enrollmentPort.getEnrollmentFormDefinition(userId)
470
+ : undefined;
471
+ if (!notEnrolled) {
472
+ return {
473
+ userId,
474
+ authState: account.authState,
475
+ state: account.state,
476
+ found: false,
477
+ noResultsReason:
478
+ account.authState === 'unauthenticated'
479
+ ? MEMBER_LOGIN_NOTICE
480
+ : account.state === 'active'
481
+ ? '当前用户已是会员,无需重复开卡'
482
+ : MEMBER_UNKNOWN_NOTICE,
483
+ };
484
+ }
485
+ const facts = {
118
486
  userId,
119
487
  state: account.state,
120
- fields:
121
- account.state === 'not_enrolled'
122
- ? [
123
- { fieldId: 'name', label: '姓名', required: true },
124
- { fieldId: 'phone', label: '手机号', required: true },
125
- ]
126
- : [],
127
- actionId: 'member.enroll',
488
+ formVersion: definition?.formVersion ?? 'unknown',
489
+ fields: definition ? [...definition.fields] : [],
490
+ consents: definition ? [...definition.consents] : [],
491
+ authorization: { status: definition?.authorizationStatus ?? 'unavailable' },
128
492
  };
493
+ return withCards(facts, 'member-enrollment-form', '未入会 · 填写表单完成入会');
129
494
  },
130
495
  });
131
496
  }
132
497
 
133
- /** 优惠券列表查询 → member-coupon-list 卡 */
134
- export function createQueryMemberCouponsTool(getMember: () => MemberCouponPort | undefined) {
498
+ /**
499
+ * queryLoginCta:登录引导卡。
500
+ *
501
+ * 与 queryEnrollmentCta 的未登录分支相比,这条是**用户主动要登录入口**时的直达通道——
502
+ * 登录诉求不只来自开卡,停车缴费、查积分、看券都会撞上。故不按当前登录态设闸:
503
+ * 用户开口要登录入口就给他,已登录的人点过去也只是看到自己已登录,无害;
504
+ * 反过来若按 authState 卡住,真正没登录的用户(此刻拿不到可信身份)反而可能被误判拦下。
505
+ * 深链为可选透传素材——accountPort.loginUrl 有则带;未配置恒出卡(无 URL 字段,客户端禁用按钮)。
506
+ */
507
+ export function createQueryLoginCtaTool(
508
+ ports: MemberToolPorts,
509
+ ): AnyTool {
135
510
  return createTool({
136
- id: 'queryMemberCoupons',
137
- description: '查询会员优惠券列表(状态/面额/门槛)',
138
- inputSchema: coerce(z.object({ userId: z.string().describe('会员 userId') })),
139
- execute: async ({ userId }: { userId: string }) => {
140
- const member = getMember();
141
- const result = member ? await member.listCoupons(userId, 1, 20) : { items: [], total: 0 };
142
- return {
143
- userId,
144
- items: result.items.map((c) => ({
145
- couponRef: c.couponRef,
146
- title: c.title,
147
- type: c.type,
148
- status: c.status,
149
- ...(c.benefitText ? { benefitText: c.benefitText } : {}),
150
- ...(c.thresholdText ? { thresholdText: c.thresholdText } : {}),
151
- })),
152
- total: result.total,
511
+ id: 'queryLoginCta',
512
+ description:
513
+ '登录引导:当用户表示要登录、说自己未登录,或其它能力因缺少登录身份无法继续时调用,' +
514
+ '返回登录引导卡(点击后跳转商场小程序登录页,完成后回到对话)。',
515
+ inputSchema: z.object({}),
516
+ execute: async (_input: Record<string, unknown>, execCtx: ToolExecContextLike) => {
517
+ const reqCtx = requireToolRequestContext(execCtx, 'queryLoginCta');
518
+ const accountPort = await ports.getAccount(reqCtx);
519
+ const loginFacts = {
520
+ title: '登录后继续',
521
+ ...(accountPort.loginUrl ? { loginUrl: accountPort.loginUrl } : {}),
153
522
  };
523
+ return withCards(loginFacts, 'member-login-cta', '未登录 · 点击卡片登录');
154
524
  },
155
525
  });
156
526
  }