@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
@@ -0,0 +1,95 @@
1
+ /**
2
+ * member ports 017 槽位原子化回归锁(五槽直连上抛,无 InMemory 兜底)。
3
+ * - 槽位命中 → adapter 产物 + service 参数 = 槽 id;
4
+ * - PROVIDER_NOT_IMPLEMENTED / 任何 registry 错误 → 上抛(member 域 fail-fast,
5
+ * 与 parking/promotion InMemory 兜底、guide 增强面兜底语义三分);
6
+ * - mallId 缺失 / registry 缺失 → 显式错误。
7
+ */
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import type { RequestContext } from '@tbox.cn/app-contracts';
10
+ import { createServerContext } from '@tbox.cn/app-sdk/server';
11
+ import type { ServerContext } from '@tbox.cn/app-sdk/server';
12
+ import {
13
+ resolveMemberAccountPort,
14
+ resolveMemberBenefitsPort,
15
+ resolveMemberCardPort,
16
+ resolveMemberEnrollmentPort,
17
+ resolveMemberProfilePort,
18
+ } from '../src/server/ports';
19
+
20
+ function requestContext(mallId = 'mall-demo', token = 'user-token'): RequestContext {
21
+ return {
22
+ identity: { userId: 'u_1001', source: 'dev' },
23
+ credentials: { provider: 'dev', token },
24
+ attributes: { mall: { miniAppId: 'demo-app', mallId } },
25
+ };
26
+ }
27
+
28
+ function ctxWithIntegrations(resolveService: (input: unknown) => Promise<unknown>): ServerContext {
29
+ const ctx = createServerContext({});
30
+ (ctx as { integrations?: unknown }).integrations = { resolveService };
31
+ (ctx as { logger?: unknown }).logger = { warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
32
+ return ctx;
33
+ }
34
+
35
+ const sentinel = { kind: 'slot-hit' } as unknown as never;
36
+
37
+ const RESOLVERS = [
38
+ { name: 'member.account', resolve: resolveMemberAccountPort },
39
+ { name: 'member.profile', resolve: resolveMemberProfilePort },
40
+ { name: 'member.card', resolve: resolveMemberCardPort },
41
+ { name: 'member.benefits', resolve: resolveMemberBenefitsPort },
42
+ { name: 'member.enrollment', resolve: resolveMemberEnrollmentPort },
43
+ ] as const;
44
+
45
+ describe('member ports 五槽解析(017 槽位原子化;fail-fast 无兜底)', () => {
46
+ for (const { name, resolve } of RESOLVERS) {
47
+ it(`[${name}] T1 槽位命中 → adapter 产物 + service 参数 = 槽 id`, async () => {
48
+ let captured: unknown;
49
+ const ctx = ctxWithIntegrations(async (input) => {
50
+ captured = input;
51
+ return sentinel;
52
+ });
53
+ await expect(resolve(requestContext(), ctx)).resolves.toBe(sentinel);
54
+ expect(captured).toMatchObject({ service: name, scope: { mallId: 'mall-demo' } });
55
+ });
56
+
57
+ it(`[${name}] T2 PROVIDER_NOT_IMPLEMENTED → 上抛(member 无兜底)`, async () => {
58
+ const ctx = ctxWithIntegrations(async () => {
59
+ throw Object.assign(new Error('无 adapter'), { code: 'PROVIDER_NOT_IMPLEMENTED' });
60
+ });
61
+ await expect(resolve(requestContext(), ctx)).rejects.toMatchObject({ code: 'PROVIDER_NOT_IMPLEMENTED' });
62
+ });
63
+
64
+ it(`[${name}] T3 预期缺席(SERVICE_NOT_CONFIGURED)→ 上抛(配置即声明;工具层转译文案)`, async () => {
65
+ const ctx = ctxWithIntegrations(async () => {
66
+ throw Object.assign(new Error('未配置'), { code: 'PROVIDER_SERVICE_NOT_CONFIGURED' });
67
+ });
68
+ await expect(resolve(requestContext(), ctx)).rejects.toMatchObject({ code: 'PROVIDER_SERVICE_NOT_CONFIGURED' });
69
+ });
70
+
71
+ it(`[${name}] T4 mallId 缺失 → 显式错误(不落空 scope)`, async () => {
72
+ const ctx = ctxWithIntegrations(async () => sentinel);
73
+ await expect(resolve({ ...requestContext(), attributes: {} }, ctx)).rejects.toThrow('缺少 mallId');
74
+ });
75
+ }
76
+
77
+ it('T5 ctx 无 integrations → 显式错误(registry 缺失不静默)', async () => {
78
+ await expect(resolveMemberAccountPort(requestContext(), createServerContext({}))).rejects.toThrow(
79
+ 'integrations registry 缺失',
80
+ );
81
+ });
82
+
83
+ it('T6 槽级隔离:account 缺席不拖累 benefits(部分绑定显式失败粒度)', async () => {
84
+ const ctx = ctxWithIntegrations(async (input: unknown) => {
85
+ if ((input as { service: string }).service === 'member.account') {
86
+ throw Object.assign(new Error('未配置'), { code: 'PROVIDER_SERVICE_NOT_CONFIGURED' });
87
+ }
88
+ return sentinel;
89
+ });
90
+ await expect(resolveMemberAccountPort(requestContext(), ctx)).rejects.toMatchObject({
91
+ code: 'PROVIDER_SERVICE_NOT_CONFIGURED',
92
+ });
93
+ await expect(resolveMemberBenefitsPort(requestContext(), ctx)).resolves.toBe(sentinel);
94
+ });
95
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createInMemoryMemoryStore, createServerContext } from '@tbox.cn/app-sdk/server';
3
+ import { createMemberHandler } from '../src/server/handler';
4
+
5
+ /** 上下文统一化:显式缝——reqCtx 由调用方透传(handle 第四参) */
6
+ function authenticatedContext(userId = 'user-1', mallId = 'mall-1') {
7
+ const memory = createInMemoryMemoryStore();
8
+ const ctx = createServerContext({ memory });
9
+ const reqCtx = {
10
+ identity: { userId, source: 'dev' as const },
11
+ attributes: { mall: { mallId, miniAppId: 'app-1', subjectId: 'subject-1' } },
12
+ };
13
+ return { ctx, memory, reqCtx };
14
+ }
15
+
16
+ describe('会员偏好长期记忆', () => {
17
+ it('三项声明式 intent 与 PRD 问题一致(exact 整句全等,不入 BM25 词袋)', () => {
18
+ const { ctx } = authenticatedContext();
19
+ expect(createMemberHandler(ctx).intents).toEqual([
20
+ { patterns: ['我常来买衣服', '我常带娃来玩', '我常来吃美食'], match: 'exact' },
21
+ ]);
22
+ });
23
+
24
+ it('登录用户点击 query 后按 userId × mallId 保存 preference', async () => {
25
+ const { ctx, memory, reqCtx } = authenticatedContext();
26
+ const result = await createMemberHandler(ctx).handle('intent', { content: '我常来买衣服' }, ctx, reqCtx);
27
+
28
+ expect(result.text).toContain('记住啦');
29
+ await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-1' } })).resolves.toMatchObject([
30
+ { kind: 'preference', content: '会员偏好:我常来买衣服' },
31
+ ]);
32
+ await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-2' } })).resolves.toEqual([]);
33
+ await expect(memory.list({ userId: 'other-user', scope: { mallId: 'mall-1' } })).resolves.toEqual([]);
34
+ });
35
+
36
+ it('保存后返回受控 Agent 续问,由 chat-engine 继续完整导购流程', async () => {
37
+ const { ctx, memory, reqCtx } = authenticatedContext();
38
+
39
+ const result = await createMemberHandler(ctx).handle('intent', { content: '我常来买衣服' }, ctx, reqCtx);
40
+
41
+ expect(result.text).toBe('记住啦,你常来买衣服。正在为你查找相关的门店和导购服务,请稍等。\n\n');
42
+ expect(result.continueWith).toEqual({ content: '推荐适合购买的服装门店和商品' });
43
+ expect(result.cards).toEqual([]);
44
+ await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-1' } })).resolves.toMatchObject([
45
+ { kind: 'preference', content: '会员偏好:我常来买衣服' },
46
+ ]);
47
+ });
48
+
49
+ it('匿名用户与 memory-off 上下文均不写入', async () => {
50
+ const memory = createInMemoryMemoryStore();
51
+ const anonymous = createServerContext({ memory });
52
+ const anonymousResult = await createMemberHandler(anonymous).handle(
53
+ 'intent',
54
+ { content: '我常来吃美食' },
55
+ anonymous,
56
+ { identity: { userId: 'anonymous', source: 'anonymous' as const } },
57
+ );
58
+ expect(anonymousResult.text).toContain('登录后');
59
+ await expect(memory.list({ userId: 'anonymous' })).resolves.toEqual([]);
60
+
61
+ const disabled = createServerContext();
62
+ const disabledResult = await createMemberHandler(disabled).handle(
63
+ 'intent',
64
+ { content: '我常带娃来玩' },
65
+ disabled,
66
+ { identity: { userId: 'user-1', source: 'dev' as const } },
67
+ );
68
+ expect(disabledResult.text).toContain('暂不可用');
69
+ });
70
+
71
+ it('不把包含偏好短语的自然语言误存为按钮选择(未识别 → 放行 LLM,零写入)', async () => {
72
+ const { ctx, memory, reqCtx } = authenticatedContext();
73
+ const result = await createMemberHandler(ctx).handle(
74
+ 'intent',
75
+ { content: '我还没决定是不是我常来买衣服' },
76
+ ctx,
77
+ reqCtx,
78
+ );
79
+
80
+ // 洋葱定律 1:未识别 = 失败 = 放行(原 12 字死文案分支改为 continueWith 续接)
81
+ expect(result.cards).toEqual([]);
82
+ expect(result.text).toBeUndefined();
83
+ expect(result.continueWith).toEqual({ content: '我还没决定是不是我常来买衣服' });
84
+ await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-1' } })).resolves.toEqual([]);
85
+ });
86
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * samples 双断言(迁移自 mall dev-gallery 会员卡面画廊守卫):
3
+ * - 全样本 dataSchema.safeParse 通过 + findSampleShapeViolations 无越界键(样本即契约);
4
+ * - 守卫转移:卡清单收口(会员域对话样本只覆盖卡面一张——开卡入会三卡随登录承接入会
5
+ * 退场、资料卡与积分流水卡由首页服务卡承接,再往回加卡得先改这里);
6
+ * - 画廊时期 autoClick 禁用守卫由 CardSample 类型天然满足(无该字段,编译期不可能携带
7
+ * 主动交互——样本只描述状态,不描述动作)。
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+ import { findSampleShapeViolations } from '@tbox.cn/app-sdk/server';
11
+ import meta from '../src/server/cards/member-card-summary/meta';
12
+
13
+ describe('member samples 双断言(样本即契约)', () => {
14
+ it('member-card-summary: 全样本过 schema + shape 无越界', () => {
15
+ expect(meta.samples?.length ?? 0).toBeGreaterThan(0);
16
+ expect(findSampleShapeViolations(meta).violations).toEqual([]);
17
+ for (const s of meta.samples ?? []) {
18
+ expect(
19
+ meta.dataSchema.safeParse(s.data).success,
20
+ s.label,
21
+ ).toBe(true);
22
+ }
23
+ });
24
+
25
+ it('卡清单收口:11 格且全部属于 member-card-summary(卡面唯一)', () => {
26
+ expect(meta.cardType).toBe('member-card-summary');
27
+ expect(meta.samples).toHaveLength(11);
28
+ });
29
+
30
+ it('四档 variant 全覆盖 + 高低档权益数量差异照实(14/15)', () => {
31
+ const variants = new Set(
32
+ (meta.samples ?? [])
33
+ .map((s) => (s.data as { presentation?: { variant?: string } }).presentation?.variant),
34
+ );
35
+ expect(variants.has('blue')).toBe(true);
36
+ expect(variants.has('silver')).toBe(true);
37
+ expect(variants.has('gold')).toBe(true);
38
+ expect(variants.has('black')).toBe(true);
39
+
40
+ const counts = (meta.samples ?? [])
41
+ .map((s) => (s.data as { benefits?: unknown[] }).benefits?.length)
42
+ .filter((n): n is number => n !== undefined);
43
+ expect(Math.max(...counts)).toBe(15);
44
+ expect(Math.min(...counts.filter((n) => n >= 10))).toBe(14);
45
+ });
46
+
47
+ it('素材中立:权益图标全部为内联 SVG 分色占位或 example.com 占位', () => {
48
+ const serialized = JSON.stringify(meta.samples ?? []);
49
+ expect(serialized).toContain('data:image/svg+xml');
50
+ // 结构性断言:每个 iconUrl 要么内联 SVG,要么 example.com 占位(外链失败分支)——不允许其他外域
51
+ for (const s of meta.samples ?? []) {
52
+ for (const b of (s.data as { benefits?: { name?: string; iconUrl?: string }[] }).benefits ?? []) {
53
+ if (!b.iconUrl) continue;
54
+ expect(
55
+ b.iconUrl.startsWith('data:image/svg+xml') || b.iconUrl.startsWith('https://assets.example.com/'),
56
+ `${s.label}/${b.name}`,
57
+ ).toBe(true);
58
+ }
59
+ }
60
+ });
61
+ });