@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.
- package/AGENTS.md +16 -0
- package/README.md +99 -11
- package/dist/chunk-KEBILLQ5.js +771 -0
- package/dist/chunk-T4SYPQA5.js +1565 -0
- package/dist/client/index.d.ts +11 -2
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +14 -4
- package/dist/login-mascot-5D4X6YZ7.jpg +0 -0
- package/dist/member-QFCIYILN.css +1734 -0
- package/dist/server/index.d.ts +928 -41
- package/dist/server/index.js +13 -3
- 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/assets/login-mascot.jpg +0 -0
- package/src/client/assets.d.ts +5 -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 +21 -4
- package/src/client/styles/member.css +1734 -0
- package/src/client/types/css.d.ts +2 -0
- package/src/server/action.ts +348 -0
- package/src/server/cards/index.ts +31 -0
- package/src/server/cards/member-card-summary/meta.ts +42 -0
- 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 +26 -0
- package/src/server/cards/member-enrollment-status/meta.ts +18 -0
- package/src/server/cards/member-info/meta.ts +19 -0
- package/src/server/cards/member-login-cta/meta.ts +16 -0
- package/src/server/cards/member-point-usage-list/meta.ts +19 -0
- package/src/server/handler.ts +170 -16
- package/src/server/index.ts +136 -24
- package/src/server/ports.ts +66 -0
- package/src/server/service.ts +188 -36
- package/src/server/status.ts +87 -0
- package/src/server/subject-binding.ts +19 -0
- package/src/server/tool.ts +514 -19
- package/src/server/util.ts +4 -0
- 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 +597 -36
- package/tests/skills.test.ts +65 -0
- package/tests/status.test.ts +127 -0
- package/tests/view.test.ts +33 -0
- package/tsup.config.ts +9 -1
- package/dist/chunk-F6FHBH3N.js +0 -160
- package/dist/chunk-OXXT6F5B.js +0 -33
- package/src/client/cards/points/index.tsx +0 -19
- package/src/server/cards/points/meta.ts +0 -22
- package/tbox.component.json +0 -20
package/src/server/index.ts
CHANGED
|
@@ -1,45 +1,157 @@
|
|
|
1
|
-
import type { ServerContext, ServerModule
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
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 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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
//
|
|
33
|
-
const
|
|
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
|
-
//
|
|
36
|
-
|
|
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';
|
package/src/server/service.ts
CHANGED
|
@@ -1,20 +1,66 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
membershipLabel: '测试会员卡',
|
|
31
113
|
};
|
|
32
114
|
}
|
|
33
115
|
|
|
34
|
-
async
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|