@tbox.cn/app-module-member 0.1.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 +11 -2
  6. package/dist/client/index.js +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +14 -4
  9. package/dist/login-mascot-5D4X6YZ7.jpg +0 -0
  10. package/dist/member-QFCIYILN.css +1734 -0
  11. package/dist/server/index.d.ts +928 -41
  12. package/dist/server/index.js +13 -3
  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 +21 -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 +31 -0
  34. package/src/server/cards/member-card-summary/meta.ts +42 -0
  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 +26 -0
  38. package/src/server/cards/member-enrollment-status/meta.ts +18 -0
  39. package/src/server/cards/member-info/meta.ts +19 -0
  40. package/src/server/cards/member-login-cta/meta.ts +16 -0
  41. package/src/server/cards/member-point-usage-list/meta.ts +19 -0
  42. package/src/server/handler.ts +170 -16
  43. package/src/server/index.ts +136 -24
  44. package/src/server/ports.ts +66 -0
  45. package/src/server/service.ts +188 -36
  46. package/src/server/status.ts +87 -0
  47. package/src/server/subject-binding.ts +19 -0
  48. package/src/server/tool.ts +514 -19
  49. package/src/server/util.ts +4 -0
  50. package/tbox.module.json +124 -0
  51. package/tests/cards-render.test.tsx +526 -0
  52. package/tests/enrollment.test.ts +121 -0
  53. package/tests/ports-resolve.test.ts +95 -0
  54. package/tests/preference-memory.test.ts +86 -0
  55. package/tests/samples.test.ts +61 -0
  56. package/tests/service.test.ts +597 -36
  57. package/tests/skills.test.ts +65 -0
  58. package/tests/status.test.ts +127 -0
  59. package/tests/view.test.ts +33 -0
  60. package/tsup.config.ts +9 -1
  61. package/dist/chunk-F6FHBH3N.js +0 -160
  62. package/dist/chunk-OXXT6F5B.js +0 -33
  63. package/src/client/cards/points/index.tsx +0 -19
  64. package/src/server/cards/points/meta.ts +0 -22
  65. package/tbox.component.json +0 -20
@@ -1,45 +1,157 @@
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';
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 memberInfoMeta from './cards/member-info/meta';
5
+ import memberCardSummaryMeta from './cards/member-card-summary/meta';
6
+ import memberPointUsageListMeta from './cards/member-point-usage-list/meta';
7
+ import memberEnrollmentFormMeta from './cards/member-enrollment-form/meta';
8
+ import memberEnrollmentStatusMeta from './cards/member-enrollment-status/meta';
9
+ import memberEnrollmentCtaMeta from './cards/member-enrollment-cta/meta';
10
+ import memberLoginCtaMeta from './cards/member-login-cta/meta';
11
+ import {
12
+ createQueryPointsTool,
13
+ createQueryMemberInfoTool,
14
+ createQueryMemberCardTool,
15
+ createQueryEnrollmentFormTool,
16
+ createQueryEnrollmentCtaTool,
17
+ createQueryLoginCtaTool,
18
+ } from './tool';
6
19
  import { createMemberHandler } from './handler';
20
+ import { createMemberActions } from './action';
21
+ import { createSubjectBinding } from './subject-binding';
22
+ import {
23
+ resolveMemberAccountPort,
24
+ resolveMemberBenefitsPort,
25
+ resolveMemberCardPort,
26
+ resolveMemberEnrollmentPort,
27
+ resolveMemberProfilePort,
28
+ } from './ports';
29
+ import { resolveMemberStatus } from './status';
7
30
 
8
31
  export { InMemoryMemberService } from './service';
9
32
  export type { MemberService } from './service';
10
- export { default as pointsMeta } from './cards/points/meta';
33
+ export {
34
+ resolveMemberAccountPort,
35
+ resolveMemberProfilePort,
36
+ resolveMemberCardPort,
37
+ resolveMemberBenefitsPort,
38
+ resolveMemberEnrollmentPort,
39
+ } from './ports';
40
+ export type { MemberAccountService, MemberEnrollmentService } from './ports';
41
+ export { memberCards } from './cards';
11
42
 
12
- /**
13
- * 模块级单例(B14):实例在模块包加载时创建,serverModule.register 幂等(重复调用共享状态)。
14
- * 服务实例生命周期 = 模块包加载期,而非 register 调用次数。
15
- */
16
- const member = new InMemoryMemberService({ u_1001: 860, u_1002: 120 });
43
+ let serverCtx: ServerContext | undefined;
44
+ const getCtx = (): ServerContext => {
45
+ if (!serverCtx) throw new Error('member 模块未注册(ServerContext 未绑定)');
46
+ return serverCtx;
47
+ };
48
+ /** 请求级 member 槽服务集(017 槽位原子化:五槽 resolver 注入工具/动作;
49
+ * 上下文统一化:显式传 reqCtx,替代 ctx.auth 全局单例) */
50
+ const memberPorts = {
51
+ getAccount: (reqCtx: RequestContext) => resolveMemberAccountPort(reqCtx, getCtx()),
52
+ getProfile: (reqCtx: RequestContext) => resolveMemberProfilePort(reqCtx, getCtx()),
53
+ getCard: (reqCtx: RequestContext) => resolveMemberCardPort(reqCtx, getCtx()),
54
+ getBenefits: (reqCtx: RequestContext) => resolveMemberBenefitsPort(reqCtx, getCtx()),
55
+ getEnrollment: (reqCtx: RequestContext) => resolveMemberEnrollmentPort(reqCtx, getCtx()),
56
+ };
57
+
58
+ /** 工具实例顶层创建;每次 execute 解析当前请求的真实 C 端 Provider。
59
+ * W2(2026-09-01):出卡工具创建点单点 wrap(description 追加预告知句)——
60
+ * 注册即 wrapped 实例;CTA/表单卡承载操作入口带 actions 句。
61
+ * PR-2:三个纯查询工具(只读/幂等/execCtx 仅消费 requestContext)标注尽早执行
62
+ * 白名单——env TBOX_EARLY_TOOL_EXECUTION=1 时参数流完即预执行;缺省 off 零行为。 */
63
+ const queryPointsTool = withEarlyExecution(
64
+ withCardEmissionHint(createQueryPointsTool(memberPorts)),
65
+ );
66
+ const queryMemberInfoTool = withEarlyExecution(
67
+ withCardEmissionHint(createQueryMemberInfoTool(memberPorts)),
68
+ );
69
+ const queryMemberCardTool = withEarlyExecution(
70
+ withCardEmissionHint(createQueryMemberCardTool(memberPorts)),
71
+ );
72
+ const queryEnrollmentFormTool = withCardEmissionHint(createQueryEnrollmentFormTool(memberPorts), {
73
+ actions: true,
74
+ });
75
+ const queryEnrollmentCtaTool = withCardEmissionHint(createQueryEnrollmentCtaTool(memberPorts), {
76
+ actions: true,
77
+ });
78
+ const queryLoginCtaTool = withCardEmissionHint(createQueryLoginCtaTool(memberPorts), {
79
+ actions: true,
80
+ });
17
81
 
18
82
  /** 卡片 meta 声明 map(key 约定 = meta.cardType;数据 + 类型源) */
19
83
  const cards = {
20
- points: pointsMeta,
84
+ 'member-info': memberInfoMeta,
85
+ 'member-card-summary': memberCardSummaryMeta,
86
+ 'member-point-usage-list': memberPointUsageListMeta,
87
+ 'member-enrollment-form': memberEnrollmentFormMeta,
88
+ 'member-enrollment-status': memberEnrollmentStatusMeta,
89
+ 'member-enrollment-cta': memberEnrollmentCtaMeta,
90
+ 'member-login-cta': memberLoginCtaMeta,
21
91
  } as const;
22
92
 
93
+ /**
94
+ * 会员前置工具暂时停用(保留原实现,便于恢复):
95
+ * queryPoints 会串联真实 MallCard/Get、BonusHistory 和卡片投影,当前前置预算下容易超时,
96
+ * 导致会员 Skill 一直处于执行中。现阶段统一走下方业务工具 queryPoints/queryMemberInfo/queryMemberCard,
97
+ * 等真实接口超时与前置预算重新评估后再恢复。
98
+ *
99
+ * const preTools = {
100
+ * queryPoints: definePreTool(queryPointsTool, {
101
+ * routing: { mode: 'always' },
102
+ * decide: ({ identity, text }) =>
103
+ * identity && identity.source !== 'anonymous'
104
+ * ? { decision: 'run', args: { userId: identity.userId, query: text } }
105
+ * : { decision: 'skip', reason: '匿名身份不预执行' },
106
+ * }),
107
+ * } satisfies Record<string, PreToolDef>;
108
+ */
109
+ const preTools = {} as const;
110
+
111
+ /** 文件加载式 skill(009 两形态之二;promotion 先例):顶层 await → 填回声明式通道(装配端 registerAll 自动注册)。
112
+ * codegen 展开改写约定:第一参随包名改写(@tbox.cn/app-module-member → @app/module-member)。 */
113
+ const skills = Object.fromEntries(
114
+ (await loadModuleSkills('@tbox.cn/app-module-member', import.meta.url)).map((s) => [s.name, s]),
115
+ );
116
+
23
117
  /** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
24
118
  export const serverModule = {
119
+ declaration: {
120
+ moduleId: 'member',
121
+ },
25
122
  cards,
123
+ preTools,
124
+ skills,
26
125
  register(ctx: ServerContext): void {
27
- ctx.services.register('member', member);
28
- ctx.tools.register(createQueryPointsTool(() => ctx.resolveService('member')));
126
+ serverCtx = ctx;
127
+ // 上下文统一化:'member.service' 解析工厂(跨模块消费方 resolveService
128
+ // await factory(reqCtx)——替代旧 'member' 全局门面,并发安全)。
129
+ // 017 槽位原子化:积分结算写面语义锚 = benefits 槽(跨模块消费方为积分域)
130
+ ctx.services.register('member.service', (reqCtx: RequestContext) =>
131
+ resolveMemberBenefitsPort(reqCtx, ctx),
132
+ );
133
+ // 会员状态解析工厂(member.status):跨模块门禁消费(如领券 acquire);
134
+ // soft 单档——缺席/失败收口 unavailable,不伪装状态
135
+ ctx.services.register('member.status', (reqCtx: RequestContext) =>
136
+ resolveMemberStatus(reqCtx, ctx),
137
+ );
138
+ ctx.tools.register(queryPointsTool);
139
+ ctx.tools.register(queryMemberInfoTool);
140
+ ctx.tools.register(queryMemberCardTool);
141
+ ctx.tools.register(queryEnrollmentFormTool);
142
+ ctx.tools.register(queryEnrollmentCtaTool);
143
+ ctx.tools.register(queryLoginCtaTool);
29
144
  ctx.cards.registerMap(cards);
30
145
  ctx.handlers.register(createMemberHandler(ctx));
31
146
 
32
- // 类型化事件总线:以 MallEventMap 收窄(keyof 约束载荷类型,编译期对齐)
33
- const mallBus = ctx.bus as unknown as TypedBus<MallEventMap>;
147
+ // 015 S4:入会四动作(register 闭包 ctx——动作 execute 内经 enrollment 槽解析 + ctx.state PII 暂存)
148
+ const actions = createMemberActions(ctx, (reqCtx) => resolveMemberEnrollmentPort(reqCtx, ctx));
149
+ for (const action of Object.values(actions)) {
150
+ ctx.cardActions.register(action);
151
+ }
34
152
 
35
- // 跨模块异步通知:parking 核销停车券 member 奖积分(member 不依赖 parking
36
- mallBus.subscribe('parking.coupon.used', async ({ userId, amount }) => {
37
- await member.awardPoints(userId, Math.floor(amount / 10));
38
- });
153
+ // 015 D9:主体绑定(promotion acquire 消费;ctx.services 跨模块 resolveService
154
+ ctx.services.register('member.subject-binding', createSubjectBinding(ctx));
39
155
 
40
- // 等级变更对外发布(member 发事件,其他模块订阅);publish 为 async(B1,fire-and-forget)
41
- member.onLevelChange(({ userId, newLevel }) => {
42
- void mallBus.publish('member.level.changed', { userId, newLevel });
43
- });
44
156
  },
45
157
  } 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';
@@ -1,20 +1,66 @@
1
- import type { MemberQueryPort, MemberSettlementPort, Points } from '@tbox.cn/app-contracts-mall';
2
- import type { DiscountRule } from '@tbox.cn/app-contracts-mall';
1
+ import type {
2
+ MemberAccountPort,
3
+ MemberAccountSnapshot,
4
+ MemberBenefitsPort,
5
+ MemberBenefitsSnapshot,
6
+ MemberCardPort,
7
+ MemberCardSnapshot,
8
+ MemberEnrollmentInput,
9
+ MemberEnrollmentPort,
10
+ MemberEnrollmentResult,
11
+ MemberEnrollmentStatus,
12
+ MemberProfilePort,
13
+ MemberProfileSnapshot,
14
+ Points,
15
+ PointUsageRecord,
16
+ } from '@tbox.cn/app-contracts-mall';
17
+ import type {
18
+ DiscountRule,
19
+ MemberEnrollmentFormDefinition,
20
+ } from '@tbox.cn/app-contracts-mall';
3
21
 
4
22
  /**
5
- * MemberService:商圈会员完整实现。
23
+ * MemberService:商圈会员完整实现(011 K2 六 Port + 015 入会升级)。
6
24
  * extends 契约 Port(保证满足消费方子集约束);完整接口只在本模块包内。
7
25
  */
8
- export interface MemberService extends MemberQueryPort, MemberSettlementPort {
9
- /** 积分变动记录(单测/观测用) */
10
- audit: { action: string; userId: string; amount: number; key?: string }[];
26
+ /**
27
+ * InMemory 演示实现(仅供隔离单测;运行时注册与 Provider 解析不得使用)。
28
+ * 017 槽位原子化后非 Port 成员归位:getEnrollmentFormDefinition 已收编
29
+ * MemberEnrollmentPort optional 方法(R13);enrollUrl/loginUrl 为槽产物自由属性
30
+ * (enrollment/account 槽的类型视图见 ports.ts);audit 转 InMemory 私有观测成员。
31
+ */
32
+ export interface MemberService
33
+ extends MemberAccountPort,
34
+ MemberProfilePort,
35
+ MemberCardPort,
36
+ MemberBenefitsPort,
37
+ MemberEnrollmentPort {
38
+ /** 入会落地页深链(alipays:// 或 H5 链接),从 deployment provider config 注入;空串表示未配置。 */
39
+ readonly enrollUrl: string;
40
+ /**
41
+ * 登录页深链,同样从 deployment provider config 注入。
42
+ * 可选而非必填——真实 provider 接入前不该被迫实现;缺省时登录引导退回文本。
43
+ */
44
+ readonly loginUrl?: string;
11
45
  }
12
46
 
13
- /** 内存实现(PoC) */
47
+ // Form 族归源 TIER1(F5 下沉:provider 出参与模块投影共用同一契约面)
48
+ export type {
49
+ MemberEnrollmentConsentDefinition,
50
+ MemberEnrollmentFormFieldDefinition,
51
+ MemberEnrollmentFormDefinition,
52
+ } from '@tbox.cn/app-contracts-mall';
53
+
54
+ /** 仅供隔离单测使用的 fixture;运行时注册与 Provider 解析不得使用。 */
14
55
  export class InMemoryMemberService implements MemberService {
56
+ readonly enrollUrl: string = '';
15
57
  private balances = new Map<string, number>();
16
- private levels = new Map<string, 'silver' | 'gold' | 'platinum'>();
17
58
  private idempotentKeys = new Set<string>();
59
+ private usages: PointUsageRecord[] = [];
60
+ private profiles = new Map<string, MemberProfileSnapshot>();
61
+ private enrollmentAttempts = new Map<string, { result: MemberEnrollmentResult; userId: string }>();
62
+ private pendingConfirmations = new Set<string>();
63
+ private statusQueries = new Map<string, number>();
18
64
  audit: { action: string; userId: string; amount: number; key?: string }[] = [];
19
65
 
20
66
  constructor(initial: Record<string, number> = {}) {
@@ -23,22 +69,82 @@ export class InMemoryMemberService implements MemberService {
23
69
  }
24
70
  }
25
71
 
72
+ // ===== 1. MemberAccountPort =====
73
+
74
+ async getAccount(userId: string): Promise<MemberAccountSnapshot> {
75
+ return {
76
+ authState: 'authenticated',
77
+ state: this.balances.has(userId) ? 'active' : 'not_enrolled',
78
+ ...(this.balances.has(userId) ? { memberRef: `member:${userId}` } : {}),
79
+ observedAt: new Date().toISOString(),
80
+ };
81
+ }
82
+
83
+ // ===== 2. MemberProfilePort =====
84
+
85
+ async getProfile(userId: string): Promise<MemberProfileSnapshot> {
86
+ return (
87
+ this.profiles.get(userId) ?? {
88
+ labels: [],
89
+ observedAt: new Date().toISOString(),
90
+ }
91
+ );
92
+ }
93
+
94
+ // ===== 3. MemberCardPort =====
95
+
96
+ async getCard(userId: string): Promise<MemberCardSnapshot | null> {
97
+ if (!this.balances.has(userId)) return null;
98
+ return {
99
+ cardRef: `card:${userId}`,
100
+ cardDisplay: '示例会员卡',
101
+ status: 'active',
102
+ observedAt: new Date().toISOString(),
103
+ };
104
+ }
105
+
106
+ // ===== 4. MemberBenefitsPort =====
107
+
26
108
  async queryPoints(userId: string): Promise<Points> {
27
109
  return {
28
110
  userId,
29
111
  balance: this.balances.get(userId) ?? 0,
30
- level: this.levelOf(userId),
112
+ membershipLabel: '测试会员卡',
31
113
  };
32
114
  }
33
115
 
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 },
116
+ async getBenefits(userId: string): Promise<MemberBenefitsSnapshot> {
117
+ return {
118
+ availablePoints: this.balances.get(userId) ?? 0,
119
+ observedAt: new Date().toISOString(),
120
+ };
121
+ }
122
+
123
+ async getEnrollmentFormDefinition(): Promise<MemberEnrollmentFormDefinition> {
124
+ return {
125
+ formVersion: 'fixture-v1',
126
+ fields: [
127
+ { fieldId: 'mobile', label: '手机号', type: 'tel', required: true, placeholder: '请输入手机号' },
128
+ { fieldId: 'displayName', label: '姓名', type: 'text', required: false },
129
+ ],
130
+ consents: [{ consentId: 'privacy', version: '1', title: '隐私政策', required: true }],
131
+ authorizationStatus: 'required',
40
132
  };
41
- return map[level];
133
+ }
134
+
135
+ async listPointUsage(
136
+ userId: string,
137
+ page: number,
138
+ pageSize: number,
139
+ ): Promise<{ items: ReadonlyArray<PointUsageRecord>; total: number }> {
140
+ const all = this.usages.filter((u) => u.usageRecordRef.startsWith(userId));
141
+ const start = (page - 1) * pageSize;
142
+ return { items: all.slice(start, start + pageSize), total: all.length };
143
+ }
144
+
145
+ async queryDiscount(userId: string): Promise<DiscountRule | undefined> {
146
+ void userId;
147
+ return undefined;
42
148
  }
43
149
 
44
150
  async deductPoints(userId: string, amount: number, idempotencyKey: string): Promise<boolean> {
@@ -48,7 +154,12 @@ export class InMemoryMemberService implements MemberService {
48
154
  this.balances.set(userId, current - amount);
49
155
  this.idempotentKeys.add(idempotencyKey);
50
156
  this.audit.push({ action: 'deduct', userId, amount, key: idempotencyKey });
51
- this.maybeUpgrade(userId);
157
+ this.usages.push({
158
+ usageRecordRef: `${userId}:${Date.now()}`,
159
+ title: '积分抵扣',
160
+ amount: `-${amount}`,
161
+ usedAt: new Date().toISOString(),
162
+ });
52
163
  return true;
53
164
  }
54
165
 
@@ -58,6 +169,12 @@ export class InMemoryMemberService implements MemberService {
58
169
  this.balances.set(userId, current + amount);
59
170
  this.idempotentKeys.add(`refund:${idempotencyKey}`);
60
171
  this.audit.push({ action: 'refund', userId, amount, key: idempotencyKey });
172
+ this.usages.push({
173
+ usageRecordRef: `${userId}:${Date.now()}`,
174
+ title: '积分退回',
175
+ amount: `+${amount}`,
176
+ usedAt: new Date().toISOString(),
177
+ });
61
178
  return true;
62
179
  }
63
180
 
@@ -65,31 +182,66 @@ export class InMemoryMemberService implements MemberService {
65
182
  const current = this.balances.get(userId) ?? 0;
66
183
  this.balances.set(userId, current + amount);
67
184
  this.audit.push({ action: 'award', userId, amount });
68
- this.maybeUpgrade(userId);
185
+ this.usages.push({
186
+ usageRecordRef: `${userId}:${Date.now()}`,
187
+ title: '消费奖励',
188
+ amount: `+${amount}`,
189
+ usedAt: new Date().toISOString(),
190
+ });
69
191
  return true;
70
192
  }
71
193
 
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
- }
194
+ // ===== 5. MemberCouponPort(0.4.0 净删——券域 promotion 承接,恒空面移除) =====
195
+
196
+ // ===== 6. MemberEnrollmentPort(015:授权预填 + 幂等 + pending 收口) =====
80
197
 
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 }));
198
+ async enroll(input: MemberEnrollmentInput): Promise<MemberEnrollmentResult> {
199
+ // 幂等:同 idempotencyKey 重入返回首果(015 W3 语义,模块顶层 IdempotencyStore 之上的双保险)
200
+ const existing = this.enrollmentAttempts.get(input.idempotencyKey);
201
+ if (existing) return existing.result;
202
+
203
+ const mobile = input.fieldValues.mobile ?? '';
204
+ if (!/^1[3-9]\d{9}$/.test(mobile)) {
205
+ return { status: 'rejected', reason: '手机号格式不正确' };
206
+ }
207
+ const result: MemberEnrollmentResult =
208
+ this.balances.has(input.userId) || this.pendingConfirmations.has(input.userId)
209
+ ? { status: 'active', memberRef: `member:${input.userId}` }
210
+ : { status: 'pending_confirmation', attemptRef: input.idempotencyKey };
211
+ if (result.status === 'active') {
212
+ this.profiles.set(input.userId, {
213
+ ...(input.fieldValues.displayName ? { displayName: input.fieldValues.displayName } : {}),
214
+ contactDisplay: mobile.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
215
+ labels: [{ labelRef: 'member', name: '会员' }],
216
+ observedAt: new Date().toISOString(),
217
+ });
218
+ } else {
219
+ this.pendingConfirmations.add(input.userId);
88
220
  }
221
+ this.enrollmentAttempts.set(input.idempotencyKey, { result, userId: input.userId });
222
+ return result;
89
223
  }
90
224
 
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);
225
+ async getEnrollmentStatus(
226
+ userId: string,
227
+ attemptRef: string,
228
+ ): Promise<{ status: MemberEnrollmentStatus; memberRef?: string; reason?: string }> {
229
+ const attempt = this.enrollmentAttempts.get(attemptRef);
230
+ if (!attempt || attempt.userId !== userId) {
231
+ return { status: 'failed', reason: '入会记录不存在或不属于当前用户' };
232
+ }
233
+ if (this.balances.has(userId)) {
234
+ return { status: 'active', memberRef: `member:${userId}` };
235
+ }
236
+ // InMemory 模拟:首次查询 pending,第二次查询确认(厂商异步确认的演示语义)
237
+ const queried = this.statusQueries.get(attemptRef) ?? 0;
238
+ this.statusQueries.set(attemptRef, queried + 1);
239
+ if (queried >= 1) {
240
+ this.balances.set(userId, 0);
241
+ this.pendingConfirmations.delete(userId);
242
+ return { status: 'active', memberRef: `member:${userId}` };
243
+ }
244
+ return { status: 'pending_confirmation' };
94
245
  }
246
+
95
247
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 会员状态解析(IO 唯一入口)+ outcome 话术单源。
3
+ * soft 单档:任何失败收口 unavailable + warn 留痕——不伪装状态、不臆测未入会。
4
+ */
5
+ import type {
6
+ MemberAccountSnapshot,
7
+ MemberOutcome,
8
+ MemberStatusResolution,
9
+ } from '@tbox.cn/app-contracts-mall';
10
+ import { classifyMemberOutcome } from '@tbox.cn/app-contracts-mall';
11
+ import type { RequestContext } from '@tbox.cn/app-contracts';
12
+ import type { ServerContext } from '@tbox.cn/app-sdk/server';
13
+ import { resolveMemberAccountPort, resolveMemberEnrollmentPort } from './ports';
14
+
15
+ /** 模块内超集(跨模块窄面 = 契约 MemberStatusResolution)。
16
+ * 017 槽位原子化:service 字段退役(跨槽 fat 类型谎言)——深链素材随 resolution 携带,
17
+ * 调用方(handler/跨模块 gate)按槽另行解析;厂商快照 memo 由共享 impl 的请求级
18
+ * transport 缓存承接(多次构造廉价,行为变化声明④)。 */
19
+ export interface ResolvedMemberStatus extends MemberStatusResolution {}
20
+
21
+ /** 模型面话术(工具 noResultsReason 消费;与现状逐字节一致) */
22
+ export const MEMBER_LOGIN_NOTICE = '请先登录;登录不要求开通会员';
23
+ export const MEMBER_UNKNOWN_NOTICE = '当前无法确认会员状态,请稍后重试';
24
+ /** 已登录未入会(GUEST/潜客):下一步是入会引导而非「无积分」——与已退会拆分 */
25
+ export const MEMBER_NOT_ENROLLED_NOTICE = '尚未入会,入会后可查询积分与服务';
26
+ /** 已退会/资格关闭:账户在但积分语义为空(入会引导卡可另出,话术不混同) */
27
+ export const MEMBER_NO_POINTS_NOTICE = '当前没有可查询的会员积分';
28
+
29
+ /** 直聊面穷尽表(handler 消费;active 走业务路径不查表——Exclude 编译锁死) */
30
+ export const MEMBER_QUERY_NOTICES: Record<Exclude<MemberOutcome | 'unavailable', 'active'>, string> = {
31
+ unauthenticated: '请先登录后再查询会员信息',
32
+ unknown: MEMBER_UNKNOWN_NOTICE,
33
+ not_enrolled: MEMBER_NOT_ENROLLED_NOTICE,
34
+ closed: MEMBER_NO_POINTS_NOTICE,
35
+ unavailable: '会员服务暂时不可用,请稍后再试',
36
+ };
37
+
38
+ /** 匿名短路不触 provider;上下文缺席/resolve/getAccount 失败 → unavailable + warn 留痕
39
+ * (管路故障收口可用性语义,不冒充身份语义)。 */
40
+ export async function resolveMemberStatus(
41
+ reqCtx: RequestContext | undefined,
42
+ ctx: ServerContext,
43
+ ): Promise<ResolvedMemberStatus> {
44
+ if (!reqCtx) {
45
+ // 上下文缺席 = 调用方管路断裂(匿名身份语义经 identity.source 表达,与此无关)
46
+ ctx.logger?.warn('会员状态解析失败:运行上下文缺席');
47
+ return { outcome: 'unavailable' };
48
+ }
49
+ if (reqCtx.identity?.source === 'anonymous') {
50
+ // 匿名也 attempt 取 loginUrl(登录引导卡降级深链):工厂建实例只读部署配置、零网络 IO;
51
+ // 未配置/无 mallId/registry 缺失 → fail-soft 无深链(消费方退文字)
52
+ let loginUrl: string | undefined;
53
+ try {
54
+ const accountPort = await resolveMemberAccountPort(reqCtx, ctx);
55
+ loginUrl = accountPort.loginUrl || undefined;
56
+ } catch {
57
+ /* fail-soft */
58
+ }
59
+ return loginUrl ? { outcome: 'unauthenticated', loginUrl } : { outcome: 'unauthenticated' };
60
+ }
61
+ // N1 双槽编排(017):account 槽承载身份判定 + loginUrl;enrollment 槽仅承载
62
+ // enrollUrl 素材——缺席/失败不拖累身份判定(缺省 = 未配置恒出卡、无 URL 字段)。
63
+ let accountPort;
64
+ let account: MemberAccountSnapshot;
65
+ try {
66
+ accountPort = await resolveMemberAccountPort(reqCtx, ctx);
67
+ account = await accountPort.getAccount(reqCtx.identity.userId);
68
+ } catch (error) {
69
+ ctx.logger?.warn(`会员状态解析失败:err=${(error as Error).message}`);
70
+ return { outcome: 'unavailable' };
71
+ }
72
+ let enrollUrl: string | undefined;
73
+ try {
74
+ const enrollmentPort = await resolveMemberEnrollmentPort(reqCtx, ctx);
75
+ enrollUrl = enrollmentPort.enrollUrl || undefined;
76
+ } catch (error) {
77
+ // enrollment 槽缺席/失败 → 无深链素材(CTA 卡仍可出,memberGateCardOf 既有设计);
78
+ // warn 留痕供诊断(配置类错误不静默),不拖累身份判定。
79
+ ctx.logger?.warn(`会员入会深链解析失败:err=${(error as Error).message}`);
80
+ }
81
+ return {
82
+ outcome: classifyMemberOutcome(reqCtx.identity, account),
83
+ account,
84
+ ...(enrollUrl ? { enrollUrl } : {}),
85
+ ...(accountPort.loginUrl ? { loginUrl: accountPort.loginUrl } : {}),
86
+ };
87
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * SubjectBindingPort 兼容实现(已 deprecated——见 contracts-mall 契约注释):
3
+ * 同步签名对任意非空 userId 恒返回平台侧派生引用,不承载会员身份语义。
4
+ * 会员状态判定与真实外部会员引用请消费 'member.status' 服务 + externalMemberRefOf。
5
+ * 保留注册仅为版本偏斜期兼容(旧消费方混装)。
6
+ */
7
+ import type { SubjectBindingPort } from '@tbox.cn/app-contracts-mall';
8
+ import { createHash } from 'node:crypto';
9
+ import type { ServerContext } from '@tbox.cn/app-sdk/server';
10
+
11
+ export function createSubjectBinding(ctx: ServerContext): SubjectBindingPort {
12
+ return {
13
+ resolveExternalMemberRef(userId: string): string | undefined {
14
+ void ctx;
15
+ // 同步签名无法访问异步 Provider,这里只提供稳定派生 ref。
16
+ return userId ? `member-ref:${createHash('sha256').update(userId).digest('hex').slice(0, 24)}` : undefined;
17
+ },
18
+ };
19
+ }