@tbox.cn/app-provider-mock 0.2.0
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/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/server/index.d.ts +246 -0
- package/dist/server/index.js +643 -0
- package/package.json +51 -0
- package/src/index.ts +5 -0
- package/src/server/index.ts +148 -0
- package/src/server/mall-info.ts +34 -0
- package/src/server/member.ts +180 -0
- package/src/server/mock-enroll-route.ts +84 -0
- package/src/server/parking.ts +182 -0
- package/src/server/promotion.ts +148 -0
- package/src/server/shopping.ts +222 -0
- package/tbox.module.json +149 -0
- package/tests/integrations-e2e.test.ts +219 -0
- package/tests/mall-info.test.ts +47 -0
- package/tests/mock-impl.test.ts +348 -0
- package/tsconfig.json +7 -0
- package/tsup.config.ts +26 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock Provider 包服务端入口(provider 拆包 F1b)。
|
|
3
|
+
*
|
|
4
|
+
* 17 槽 local 供给(member×5 / parking×3 / promotion×4 / guide×4 + mall.info;manifest
|
|
5
|
+
* contributes.providers.slots,local:true):F2 模块 ports
|
|
6
|
+
* slot 一行化后经 createIntegrationsRegistry.resolveService 解析——local 短路免传输/
|
|
7
|
+
* 凭据/实例配置。实现自持(R3-1:provider 不得依赖业务模块——workspace-dag 铁律),
|
|
8
|
+
* demo ref 恒 `demo:` 前缀,厂商中立。
|
|
9
|
+
*
|
|
10
|
+
* F1b 阶段形态:adapter 工厂注册经 ctx.integrations(F2 模板装配 createIntegrationsRegistry
|
|
11
|
+
* 接线后生效);F1b 前模块仍走探测链,本包装配无行为影响。shopping-guide 槽四键形状
|
|
12
|
+
* (catalog 空 + shop/category/directory 纯 mock 数据);assistance/navigation 本地知识库域
|
|
13
|
+
* 归模块本地 adapter,不属 provider 供给面。
|
|
14
|
+
*
|
|
15
|
+
* 入会体验闭环(C5)追加两件:
|
|
16
|
+
* - scoped mock 换码器(ctx.authExchanges):固定 demo-user、GUEST/MEMBER 双形态对齐
|
|
17
|
+
* 真实厂商换码协议(潜客 token=''+openid ↔ 会员 token+memberId)——mock 档冷登走与
|
|
18
|
+
* 真实档同一条换码链,H5 侧代码零分叉。刻意不实现 devExternalCredentials:dev-login
|
|
19
|
+
* 走协议 fail-soft 无 claim 签发(login-routes 三层解析能力口缺席即跳过)。
|
|
20
|
+
* - mock 入会落地路由(ctx.routes):POST /demo/member-enroll(容器 mock-enrollment 页
|
|
21
|
+
* 回调 + curl 联调)。
|
|
22
|
+
*/
|
|
23
|
+
import type { ServerContext, ServerModule } from '@tbox.cn/app-sdk/server';
|
|
24
|
+
import type { ServiceAdapterFactory } from '@tbox.cn/app-sdk/server';
|
|
25
|
+
import type { ExchangeResult } from '@tbox.cn/app-contracts';
|
|
26
|
+
import { readLoginMallId } from '@tbox.cn/app-contracts-mall';
|
|
27
|
+
import {
|
|
28
|
+
MockMemberServiceImpl,
|
|
29
|
+
MOCK_SHELL_USER_ID,
|
|
30
|
+
isMockMemberEnrolled,
|
|
31
|
+
isMockMemberLoggedOut,
|
|
32
|
+
} from './member';
|
|
33
|
+
import { MockParkingServiceImpl } from './parking';
|
|
34
|
+
import { MockPromotionServiceImpl } from './promotion';
|
|
35
|
+
import { MockShoppingServiceImpl } from './shopping';
|
|
36
|
+
import { MOCK_MALL_INFO_PROVIDER, MOCK_MALL_INFO_IMPL, mockMallInfoFactory } from './mall-info';
|
|
37
|
+
import { createMockEnrollRouter } from './mock-enroll-route';
|
|
38
|
+
|
|
39
|
+
export const MOCK_PROVIDER = 'mock';
|
|
40
|
+
export const MOCK_MEMBER_IMPL = 'mock-member@1';
|
|
41
|
+
export const MOCK_PARKING_IMPL = 'mock-parking@1';
|
|
42
|
+
export const MOCK_PROMOTION_IMPL = 'mock-promotion@1';
|
|
43
|
+
export const MOCK_SHOPPING_IMPL = 'mock-shopping@1';
|
|
44
|
+
|
|
45
|
+
export { MockMemberServiceImpl, MOCK_SHELL_USER_ID, enrollMockMember, isMockMemberEnrolled, logoutMockMember, isMockMemberLoggedOut, __resetMockMemberState } from './member';
|
|
46
|
+
export { MockParkingServiceImpl } from './parking';
|
|
47
|
+
export { MockPromotionServiceImpl } from './promotion';
|
|
48
|
+
export { MockShoppingServiceImpl } from './shopping';
|
|
49
|
+
export { MOCK_MALL_INFO_PROVIDER, MOCK_MALL_INFO_IMPL, mockMallInfoFactory } from './mall-info';
|
|
50
|
+
export { createMockEnrollRouter } from './mock-enroll-route';
|
|
51
|
+
|
|
52
|
+
const memberFactory: ServiceAdapterFactory = () => new MockMemberServiceImpl();
|
|
53
|
+
const parkingFactory: ServiceAdapterFactory = (input) => new MockParkingServiceImpl(input.instanceId || 'mall-demo');
|
|
54
|
+
const promotionFactory: ServiceAdapterFactory = () => new MockPromotionServiceImpl();
|
|
55
|
+
/** shopping-guide:四槽分支(017 槽位原子化)——MockShoppingServiceImpl 实现全部四 Port,
|
|
56
|
+
* 结构类型满足任一槽产物(catalog 槽 = mock 空商品数据,对齐真实档无商品端点形态)。 */
|
|
57
|
+
const shoppingFactory: ServiceAdapterFactory = (input) => {
|
|
58
|
+
if (
|
|
59
|
+
input.service !== 'shopping-guide.catalog'
|
|
60
|
+
&& input.service !== 'shopping-guide.shops'
|
|
61
|
+
&& input.service !== 'shopping-guide.categories'
|
|
62
|
+
&& input.service !== 'shopping-guide.directory'
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(`mock guide 工厂不供给槽:${input.service}`);
|
|
65
|
+
}
|
|
66
|
+
return new MockShoppingServiceImpl();
|
|
67
|
+
};
|
|
68
|
+
/** mall.info:动态商场演示源(任意合法 mallId → 确定性别名;对齐真实厂商 mall-info adapter 通用形态) */
|
|
69
|
+
const mallInfoFactory: ServiceAdapterFactory = (input) => mockMallInfoFactory(input);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* scoped mock 换码器(register 期闭包 ctx.integrations——matches 谓词零 IO 纪律):
|
|
73
|
+
* matches 借 member.account 绑定路由(017 槽位原子化:换码器产出登录态身份 → account 槽
|
|
74
|
+
* 为语义锚;mock 档无独立 auth 槽;无条件兜底位归部署装配的自定义换码器,本候选恒
|
|
75
|
+
* scoped 层 first-match)。authCode 忽略(mock 语义:单 demo 用户/部署,无真实授权码可换)。
|
|
76
|
+
* 统一范式:context 无 mallId(未选择商场)→ 命中本候选,exchangeByCode 显性抛错,
|
|
77
|
+
* 不落内置本地 alipay(缺 env 报错误导部署)。
|
|
78
|
+
*/
|
|
79
|
+
function createMockAlipayLoginExchange(integrations: ServerContext['integrations']) {
|
|
80
|
+
return {
|
|
81
|
+
provider: 'alipay' as const,
|
|
82
|
+
matches(context?: Readonly<Record<string, unknown>>) {
|
|
83
|
+
const mallId = readLoginMallId(context);
|
|
84
|
+
// 未选择商场(context 无 mallId)→ 命中本换码器,由 exchangeByCode 显性抛
|
|
85
|
+
// AUTH_NOT_CONFIGURED(503)——不落内置本地 alipay(缺 env 报错误导部署)。
|
|
86
|
+
// 仅当 mallId 在场但不在 mock 绑定时才 return false(继续 fallback,多厂商并存语义)。
|
|
87
|
+
if (!mallId) return true;
|
|
88
|
+
if (!integrations) return false;
|
|
89
|
+
return integrations.hasProviderBinding('member.account', { mallId }, MOCK_PROVIDER, MOCK_MEMBER_IMPL);
|
|
90
|
+
},
|
|
91
|
+
async exchangeByCode(
|
|
92
|
+
_code: string,
|
|
93
|
+
_platform?: string,
|
|
94
|
+
context?: Readonly<Record<string, unknown>>,
|
|
95
|
+
): Promise<ExchangeResult> {
|
|
96
|
+
// 未选择商场前置校验(与 matches 语义一致:域缺失显性报错,不产出 demo 身份)
|
|
97
|
+
if (!readLoginMallId(context)) {
|
|
98
|
+
throw new Error('AUTH_NOT_CONFIGURED: 未选择商场(登录 context 无 mallId)');
|
|
99
|
+
}
|
|
100
|
+
const userId = MOCK_SHELL_USER_ID;
|
|
101
|
+
// 会员形态需登录态健康:登出态(loggedOut)降级 GUEST 形态——对齐真实档 silentLogin 信封
|
|
102
|
+
// 非 200 降级(2026-09-02 真机实测):登出后换码 = 潜客同构结果(token=''),H5 侧
|
|
103
|
+
// not_enrolled → 入会引导卡 → 半屏 getLoginState 闭环恢复;enroll 清登出态后冷登回 MEMBER。
|
|
104
|
+
if (isMockMemberEnrolled(userId) && !isMockMemberLoggedOut(userId)) {
|
|
105
|
+
// MEMBER 形态(对齐真实档会员换码输出):token + memberId 随 attributes 流动
|
|
106
|
+
return {
|
|
107
|
+
identity: { userId, source: 'external' as const },
|
|
108
|
+
credentials: {
|
|
109
|
+
token: `demo:token:${userId}`,
|
|
110
|
+
provider: 'mock',
|
|
111
|
+
attributes: { memberId: userId },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// GUEST 形态(对齐真实档潜客分支):token='' + openid 身份——「合法登录未入会」;
|
|
116
|
+
// 登出降级同走此构造(单源)。真实档另携 visitortoken,mock 链路无消费方故极简。
|
|
117
|
+
return {
|
|
118
|
+
identity: { userId, source: 'external' as const },
|
|
119
|
+
credentials: {
|
|
120
|
+
token: '',
|
|
121
|
+
provider: 'mock',
|
|
122
|
+
attributes: { openid: `demo:openid:${userId}` },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cards = {} as const;
|
|
130
|
+
|
|
131
|
+
export const serverModule = {
|
|
132
|
+
declaration: {
|
|
133
|
+
moduleId: 'provider-mock',
|
|
134
|
+
},
|
|
135
|
+
cards,
|
|
136
|
+
register(ctx: ServerContext): void {
|
|
137
|
+
// adapter 注册(v2 双键:provider 厂商名 + implementation)
|
|
138
|
+
ctx.integrations?.registerAdapter(MOCK_PROVIDER, MOCK_MEMBER_IMPL, memberFactory);
|
|
139
|
+
ctx.integrations?.registerAdapter(MOCK_PROVIDER, MOCK_PARKING_IMPL, parkingFactory);
|
|
140
|
+
ctx.integrations?.registerAdapter(MOCK_PROVIDER, MOCK_PROMOTION_IMPL, promotionFactory);
|
|
141
|
+
ctx.integrations?.registerAdapter(MOCK_PROVIDER, MOCK_SHOPPING_IMPL, shoppingFactory);
|
|
142
|
+
ctx.integrations?.registerAdapter(MOCK_MALL_INFO_PROVIDER, MOCK_MALL_INFO_IMPL, mallInfoFactory);
|
|
143
|
+
// scoped mock 换码器(member.account 绑 mock 的部署命中;其他部署不路由本包)
|
|
144
|
+
ctx.authExchanges.register(createMockAlipayLoginExchange(ctx.integrations));
|
|
145
|
+
// mock 入会落地路由(POST /api/m/provider-mock/demo/member-enroll)
|
|
146
|
+
ctx.routes.register('provider-mock', createMockEnrollRouter());
|
|
147
|
+
},
|
|
148
|
+
} satisfies ServerModule;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock 商场基础信息 adapter(mall.info 槽供给;对齐真实厂商 mall-info adapter 通用形态)。
|
|
3
|
+
*
|
|
4
|
+
* 动态商场演示源:任意 mallId → 恒返回确定性别名(`示例商场(<mallId>)`)——
|
|
5
|
+
* 动态 mallId 会话的品牌区 mallName 有 mock 真源(三级回退链第 2 级命中)。
|
|
6
|
+
* 固定 demo 地址/经纬度服务 mall-info agent tool 答问演示(融合远端 PR 363 演示语义)。
|
|
7
|
+
*
|
|
8
|
+
* 关键约束(对齐真实厂商 adapter 先例):
|
|
9
|
+
* - mallId 形状守卫(/^[\w-]{1,32}$/):畸形 ID 直接返回 undefined(S4 DoS 防线——
|
|
10
|
+
* 记录≠校验教训:请求形状断言在 provider adapter 单测锁死)
|
|
11
|
+
* - fail-soft 契约恒满足:本实现无 IO,不可能失败——契约面与真实档保持一致
|
|
12
|
+
*/
|
|
13
|
+
import type { MallInfo, MallInfoPort } from '@tbox.cn/app-contracts-mall';
|
|
14
|
+
import type { ServiceAdapterFactory } from '@tbox.cn/app-sdk/server';
|
|
15
|
+
|
|
16
|
+
export const MOCK_MALL_INFO_PROVIDER = 'mock';
|
|
17
|
+
export const MOCK_MALL_INFO_IMPL = 'mock-mall-info@1';
|
|
18
|
+
|
|
19
|
+
/** mallId 形状守卫(防注入类字符;畸形 → undefined——与真实厂商 adapter 同规则) */
|
|
20
|
+
const MALL_ID_RE = /^[\w-]{1,32}$/;
|
|
21
|
+
|
|
22
|
+
/** mock mall.info adapter 工厂(对齐真实厂商 adapter 工厂形态) */
|
|
23
|
+
export const mockMallInfoFactory: ServiceAdapterFactory = (): MallInfoPort => ({
|
|
24
|
+
async getMallInfo(mallId: string): Promise<MallInfo | undefined> {
|
|
25
|
+
if (!MALL_ID_RE.test(mallId)) return undefined;
|
|
26
|
+
return {
|
|
27
|
+
mallId,
|
|
28
|
+
name: `示例商场(${mallId})`,
|
|
29
|
+
address: '示例商场地址(demo)',
|
|
30
|
+
latitude: 30.0,
|
|
31
|
+
longitude: 120.0,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock member 实现(provider-mock 自持——R3-1 裁决:provider 不得依赖业务模块,实现自持
|
|
3
|
+
* 而非包装 module-member InMemory)。演示面最小集:开户/卡面/积分/流水/入会闭环
|
|
4
|
+
* (demo ref 恒 `demo:` 前缀,厂商中立)。
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
MemberAccountPort,
|
|
8
|
+
MemberAccountSnapshot,
|
|
9
|
+
MemberBenefitsPort,
|
|
10
|
+
MemberBenefitsSnapshot,
|
|
11
|
+
MemberCardPort,
|
|
12
|
+
MemberCardSnapshot,
|
|
13
|
+
MemberEnrollmentPort,
|
|
14
|
+
MemberEnrollmentInput,
|
|
15
|
+
MemberEnrollmentResult,
|
|
16
|
+
MemberEnrollmentFormDefinition,
|
|
17
|
+
MemberProfilePort,
|
|
18
|
+
MemberProfileSnapshot,
|
|
19
|
+
PointUsageRecord,
|
|
20
|
+
Points,
|
|
21
|
+
DiscountRule,
|
|
22
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
23
|
+
|
|
24
|
+
export type MockMemberService = MemberAccountPort &
|
|
25
|
+
MemberProfilePort &
|
|
26
|
+
MemberCardPort &
|
|
27
|
+
MemberBenefitsPort &
|
|
28
|
+
MemberEnrollmentPort;
|
|
29
|
+
|
|
30
|
+
const DEMO_USAGE: PointUsageRecord[] = [
|
|
31
|
+
{ usageRecordRef: 'demo:usage:1', title: '开卡礼', amount: '+300', usedAt: new Date().toISOString(), description: 'mock 演示流水' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mock member 状态单源(模块级共享):factory 每请求新建实例,但状态必须跨实例共享——
|
|
36
|
+
* 入会闭环(mock-enrollment 容器页回调翻状态 → 冷登换码/业务查询读同一份状态)依赖此可见性。
|
|
37
|
+
*/
|
|
38
|
+
const mockMemberState = {
|
|
39
|
+
enrolled: new Set<string>(),
|
|
40
|
+
balances: new Map<string, number>(),
|
|
41
|
+
loggedOut: new Set<string>(), // 登出态:已入会但登录态失效(对齐真实档 silentLogin 非 200 语义)
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** mock 壳侧登录用户(换码器固定身份;mock 语义 = 单 demo 用户/部署) */
|
|
45
|
+
export const MOCK_SHELL_USER_ID = 'demo-user';
|
|
46
|
+
|
|
47
|
+
/** 入会落地(幂等):enroll 路由/enroll() 共用;余额缺省 300;登录成功即清除登出态 */
|
|
48
|
+
export function enrollMockMember(userId: string): void {
|
|
49
|
+
mockMemberState.enrolled.add(userId);
|
|
50
|
+
mockMemberState.loggedOut.delete(userId);
|
|
51
|
+
if (!mockMemberState.balances.has(userId)) mockMemberState.balances.set(userId, 300);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isMockMemberEnrolled(userId: string): boolean {
|
|
55
|
+
return mockMemberState.enrolled.has(userId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 登出标记(mock 档登出态模拟:仅对已入会用户有意义;重登录即清除) */
|
|
59
|
+
export function logoutMockMember(userId: string): void {
|
|
60
|
+
mockMemberState.loggedOut.add(userId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isMockMemberLoggedOut(userId: string): boolean {
|
|
64
|
+
return mockMemberState.loggedOut.has(userId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 测试复位(沿 __resetAuthClientSingleton 先例——模块级共享状态可重置) */
|
|
68
|
+
export function __resetMockMemberState(): void {
|
|
69
|
+
mockMemberState.enrolled.clear();
|
|
70
|
+
mockMemberState.balances.clear();
|
|
71
|
+
mockMemberState.loggedOut.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 会员判定单谓词:入会 ∧ 登录态健康。登出态 = not_enrolled——对齐真实档降级会话语义
|
|
75
|
+
*(token 空 + visitortoken 在场 → not_enrolled,驱动入会引导卡/半屏而非误报 active;
|
|
76
|
+
* M1 全局态判定:mock 单用户不变式下与真实档会话凭证判定可观测等价,M2 会话凭证同构为被拒备选) */
|
|
77
|
+
function isMockMemberActive(userId: string): boolean {
|
|
78
|
+
return mockMemberState.enrolled.has(userId) && !mockMemberState.loggedOut.has(userId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class MockMemberServiceImpl implements MockMemberService {
|
|
82
|
+
/**
|
|
83
|
+
* 开卡入会落地页深链(module-member MemberService.enrollUrl 接口面——CTA 卡深链为可选
|
|
84
|
+
* 透传素材,mock 出厂面恒配 demo 值)。demo 值厂商中立(appId=demo),点击在壳内
|
|
85
|
+
* navigateTo 失败即无害降级。
|
|
86
|
+
*/
|
|
87
|
+
readonly enrollUrl = 'alipays://platformapi/startapp?appId=demo&page=v/index/index';
|
|
88
|
+
/** 登录页深链(同上,可选透传素材;供未登录引导卡跳转)。 */
|
|
89
|
+
readonly loginUrl = 'alipays://platformapi/startapp?appId=demo&page=v/login/index';
|
|
90
|
+
|
|
91
|
+
/** 默认访客(无种子):demo-user 初始 not_enrolled,经入会闭环翻为 active */
|
|
92
|
+
constructor(initial: Record<string, number> = {}) {
|
|
93
|
+
for (const [userId, balance] of Object.entries(initial)) {
|
|
94
|
+
mockMemberState.enrolled.add(userId);
|
|
95
|
+
mockMemberState.balances.set(userId, balance);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async getAccount(userId: string): Promise<MemberAccountSnapshot> {
|
|
100
|
+
const active = isMockMemberActive(userId);
|
|
101
|
+
return {
|
|
102
|
+
authState: 'authenticated',
|
|
103
|
+
state: active ? 'active' : 'not_enrolled',
|
|
104
|
+
...(active ? { memberRef: `demo:member:${userId}` } : {}),
|
|
105
|
+
observedAt: new Date().toISOString(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getProfile(_userId: string): Promise<MemberProfileSnapshot> {
|
|
110
|
+
return { labels: [{ labelRef: 'demo:label:1', name: '示例会员' }], observedAt: new Date().toISOString() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async getCard(userId: string): Promise<MemberCardSnapshot | null> {
|
|
114
|
+
if (!isMockMemberActive(userId)) return null;
|
|
115
|
+
return {
|
|
116
|
+
cardRef: `demo:card:${userId}`,
|
|
117
|
+
cardDisplay: '示例会员卡',
|
|
118
|
+
status: 'active',
|
|
119
|
+
observedAt: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async queryPoints(userId: string): Promise<Points> {
|
|
124
|
+
return { userId, balance: mockMemberState.balances.get(userId) ?? 0, membershipLabel: '示例会员卡' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async getBenefits(_userId: string): Promise<MemberBenefitsSnapshot> {
|
|
128
|
+
return { availablePoints: 300, levelName: '示例等级', observedAt: new Date().toISOString() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async listPointUsage(_userId: string, page: number, pageSize: number) {
|
|
132
|
+
const start = (page - 1) * pageSize;
|
|
133
|
+
return { items: DEMO_USAGE.slice(start, start + pageSize), total: DEMO_USAGE.length };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async queryDiscount(): Promise<DiscountRule | undefined> {
|
|
137
|
+
return { level: 'silver', discount: 0.95 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async deductPoints(userId: string, amount: number, _key: string): Promise<boolean> {
|
|
141
|
+
const balance = mockMemberState.balances.get(userId) ?? 0;
|
|
142
|
+
if (balance < amount) return false;
|
|
143
|
+
mockMemberState.balances.set(userId, balance - amount);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async refundPoints(userId: string, amount: number, _key: string): Promise<boolean> {
|
|
148
|
+
mockMemberState.balances.set(userId, (mockMemberState.balances.get(userId) ?? 0) + amount);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async awardPoints(userId: string, amount: number): Promise<boolean> {
|
|
153
|
+
mockMemberState.balances.set(userId, (mockMemberState.balances.get(userId) ?? 0) + amount);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async enroll(input: MemberEnrollmentInput): Promise<MemberEnrollmentResult> {
|
|
158
|
+
enrollMockMember(input.userId);
|
|
159
|
+
return { status: 'active', memberRef: `demo:member:${input.userId}` };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getEnrollmentStatus(userId: string, _attemptRef: string) {
|
|
163
|
+
return mockMemberState.enrolled.has(userId)
|
|
164
|
+
? { status: 'active' as const, memberRef: `demo:member:${userId}` }
|
|
165
|
+
: { status: 'failed' as const, reason: 'mock:未入会' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 入会表单定义(模块 queryEnrollmentForm 工具消费;无参对齐 InMemory 先例) */
|
|
169
|
+
async getEnrollmentFormDefinition(): Promise<MemberEnrollmentFormDefinition> {
|
|
170
|
+
return {
|
|
171
|
+
formVersion: 'demo-v1',
|
|
172
|
+
fields: [
|
|
173
|
+
{ fieldId: 'mobile', label: '手机号', type: 'tel', required: true, placeholder: '请输入手机号' },
|
|
174
|
+
{ fieldId: 'displayName', label: '姓名', type: 'text', required: false },
|
|
175
|
+
],
|
|
176
|
+
consents: [{ consentId: 'privacy', version: '1', title: '隐私政策', required: true }],
|
|
177
|
+
authorizationStatus: 'required',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock 入会落地点路由(入会体验闭环 C5):POST /api/m/provider-mock/demo/member-enroll。
|
|
3
|
+
*
|
|
4
|
+
* 容器 mock-enrollment 页(dev 脚手架)与 curl 联调共用:
|
|
5
|
+
* - body `{userId?}`:缺省 MOCK_SHELL_USER_ID(mock 页无参——与真实 gate 无参协议面一致;
|
|
6
|
+
* curl 可定向翻 dev_h5 等浏览器 dev 用户造会员态调试)。
|
|
7
|
+
* - 幂等(Set.add / 余额保守写);NODE_ENV=production → 503(provider-mock 不进生产装配,双保险)。
|
|
8
|
+
* - 经全局 /api 鉴权(AUTH_MODE 缺省 required)——调用方须携 dev-login 令牌;
|
|
9
|
+
* 匿名写显式拒绝(不随 AUTH_MODE 放宽)。
|
|
10
|
+
* - doctor naked-write-route:/demo 前缀在白名单内(webhook/upload/demo/run)。
|
|
11
|
+
*
|
|
12
|
+
* 决策逻辑抽纯函数 resolveMockEnroll(单测直达,路由层只做 HTTP 形状映射)。
|
|
13
|
+
*/
|
|
14
|
+
import { Router } from 'express';
|
|
15
|
+
import type { Request, Response } from 'express';
|
|
16
|
+
import { getRequestContext } from '@tbox.cn/app-sdk/server';
|
|
17
|
+
import { MOCK_SHELL_USER_ID, enrollMockMember, logoutMockMember } from './member';
|
|
18
|
+
|
|
19
|
+
export type MockEnrollOutcome =
|
|
20
|
+
| { status: 200; body: { state: 'active'; memberRef: string } }
|
|
21
|
+
| { status: 401; body: { error: string } }
|
|
22
|
+
| { status: 503; body: { error: string } };
|
|
23
|
+
|
|
24
|
+
/** 纯决策:production 拒 503 → 匿名/无身份拒 401 → 翻状态返 200(幂等) */
|
|
25
|
+
export function resolveMockEnroll(input: {
|
|
26
|
+
body?: unknown;
|
|
27
|
+
identity?: { source: string } | null;
|
|
28
|
+
nodeEnv?: string;
|
|
29
|
+
}): MockEnrollOutcome {
|
|
30
|
+
if ((input.nodeEnv ?? process.env.NODE_ENV) === 'production') {
|
|
31
|
+
return { status: 503, body: { error: 'mock-only' } };
|
|
32
|
+
}
|
|
33
|
+
if (!input.identity || input.identity.source === 'anonymous') {
|
|
34
|
+
return { status: 401, body: { error: 'unauthorized' } };
|
|
35
|
+
}
|
|
36
|
+
const body = (input.body ?? {}) as { userId?: unknown };
|
|
37
|
+
const userId = typeof body.userId === 'string' && body.userId.trim()
|
|
38
|
+
? body.userId.trim()
|
|
39
|
+
: MOCK_SHELL_USER_ID;
|
|
40
|
+
enrollMockMember(userId);
|
|
41
|
+
return { status: 200, body: { state: 'active', memberRef: `demo:member:${userId}` } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type MockLogoutOutcome =
|
|
45
|
+
| { status: 200; body: { state: 'logged_out' } }
|
|
46
|
+
| { status: 401; body: { error: string } }
|
|
47
|
+
| { status: 503; body: { error: string } };
|
|
48
|
+
|
|
49
|
+
/** 纯决策:production 拒 503 → 匿名/无身份拒 401 → 翻登出态返 200(与 enroll 同形) */
|
|
50
|
+
export function resolveMockLogout(input: {
|
|
51
|
+
body?: unknown;
|
|
52
|
+
identity?: { source: string } | null;
|
|
53
|
+
nodeEnv?: string;
|
|
54
|
+
}): MockLogoutOutcome {
|
|
55
|
+
if ((input.nodeEnv ?? process.env.NODE_ENV) === 'production') {
|
|
56
|
+
return { status: 503, body: { error: 'mock-only' } };
|
|
57
|
+
}
|
|
58
|
+
if (!input.identity || input.identity.source === 'anonymous') {
|
|
59
|
+
return { status: 401, body: { error: 'unauthorized' } };
|
|
60
|
+
}
|
|
61
|
+
const body = (input.body ?? {}) as { userId?: unknown };
|
|
62
|
+
const userId = typeof body.userId === 'string' && body.userId.trim()
|
|
63
|
+
? body.userId.trim()
|
|
64
|
+
: MOCK_SHELL_USER_ID;
|
|
65
|
+
logoutMockMember(userId);
|
|
66
|
+
return { status: 200, body: { state: 'logged_out' } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createMockEnrollRouter(): Router {
|
|
70
|
+
const router = Router();
|
|
71
|
+
router.post('/demo/member-enroll', (req: Request, res: Response) => {
|
|
72
|
+
const identity = getRequestContext(req)?.identity;
|
|
73
|
+
const outcome = resolveMockEnroll({ body: req.body, identity });
|
|
74
|
+
res.status(outcome.status).json(outcome.body);
|
|
75
|
+
});
|
|
76
|
+
// mock 登出路由(curl 联调/测试:翻登出态 → H5 重进换码降级为访客(与未入会一致),
|
|
77
|
+
// 调会员服务时经入会半屏 → getLoginState 闭环恢复,enroll 即清登出态)
|
|
78
|
+
router.post('/demo/member-logout', (req: Request, res: Response) => {
|
|
79
|
+
const identity = getRequestContext(req)?.identity;
|
|
80
|
+
const outcome = resolveMockLogout({ body: req.body, identity });
|
|
81
|
+
res.status(outcome.status).json(outcome.body);
|
|
82
|
+
});
|
|
83
|
+
return router;
|
|
84
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock parking 实现(provider-mock 自持——R3-1 裁决)。演示面最小集:报价/车辆/记录/
|
|
3
|
+
* 综合信息/权益/寻车 + 三步支付链 mock 闭环(reconcile 恒 PAID 收口)。demo ref 恒
|
|
4
|
+
* `demo:` 前缀,厂商中立;车牌未提供时以演示车(demo-plate)兜底。
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
ParkingBenefit,
|
|
8
|
+
ParkingInfo,
|
|
9
|
+
ParkingLocation,
|
|
10
|
+
ParkingPaymentPort,
|
|
11
|
+
ParkingPaymentRecord,
|
|
12
|
+
ParkingQueryPort,
|
|
13
|
+
ParkingRecordPage,
|
|
14
|
+
ParkingVehicle,
|
|
15
|
+
ParkingVehiclePort,
|
|
16
|
+
ParkingQueryContext,
|
|
17
|
+
QuoteSnapshot,
|
|
18
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
19
|
+
|
|
20
|
+
export type MockParkingService = ParkingQueryPort & ParkingPaymentPort & ParkingVehiclePort;
|
|
21
|
+
|
|
22
|
+
const now = () => new Date().toISOString();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* kind 必须下发:客户端按它分流(discount = 自动生效、不进勾选区;points = 积分入口),
|
|
26
|
+
* 服务端 projectPaymentOptions 也只认 kind==='points'。缺省时整套分流静默失效——
|
|
27
|
+
* 等级权益会退化成一个可勾选项,积分入口则从不出现。
|
|
28
|
+
*/
|
|
29
|
+
const DEMO_BENEFITS: ParkingBenefit[] = [
|
|
30
|
+
{ benefitRef: 'demo:benefit:member-discount', title: '会员 95 折', description: 'mock 演示权益', kind: 'discount', autoSelected: true, estimatedDiscountCents: 75 },
|
|
31
|
+
{ benefitRef: 'demo:benefit:points-offset', title: '积分抵 5 元', description: 'mock 演示权益', kind: 'points', autoSelected: false, estimatedDiscountCents: 500, requiredPoints: 500, previewPayableCents: 1000 },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function demoQuote(q: ParkingQueryContext, plateNo: string): QuoteSnapshot {
|
|
35
|
+
return {
|
|
36
|
+
quoteRef: `demo:quote:${plateNo}`,
|
|
37
|
+
plateNo,
|
|
38
|
+
mallId: q.scope.mallId ?? 'demo',
|
|
39
|
+
entryAt: now(),
|
|
40
|
+
feeItems: [{ name: '停车费(3 小时)', amountCents: 1500 }],
|
|
41
|
+
totalCents: 1500,
|
|
42
|
+
discountCents: 0,
|
|
43
|
+
payableCents: 1500,
|
|
44
|
+
appliedBenefits: [],
|
|
45
|
+
revision: 1,
|
|
46
|
+
paymentState: 'UNPAID',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class MockParkingServiceImpl implements MockParkingService {
|
|
51
|
+
private vehicles = new Map<string, ParkingVehicle>();
|
|
52
|
+
private records: ParkingPaymentRecord[] = [
|
|
53
|
+
{
|
|
54
|
+
recordRef: 'demo:record:1',
|
|
55
|
+
plateNo: 'demo-plate',
|
|
56
|
+
amountCents: 1500,
|
|
57
|
+
paidAt: now(),
|
|
58
|
+
status: 'PAID',
|
|
59
|
+
parkName: '示例停车场',
|
|
60
|
+
orderCompleted: true,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
constructor(mallId = 'mall-demo') {
|
|
65
|
+
this.mallId = mallId;
|
|
66
|
+
void this.mallId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private mallId: string;
|
|
70
|
+
|
|
71
|
+
async queryFee(q: ParkingQueryContext, input: { plateNo?: string; vehicleRef?: string }): Promise<readonly QuoteSnapshot[]> {
|
|
72
|
+
const plateNo = input.plateNo ?? (input.vehicleRef ? this.vehicles.get(input.vehicleRef)?.plateNo : undefined) ?? 'demo-plate';
|
|
73
|
+
const quote = demoQuote(q, plateNo);
|
|
74
|
+
return quote ? [quote] : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async listVehicles(): Promise<{ bound: readonly ParkingVehicle[] }> {
|
|
78
|
+
// mock 有报价即视作在场:demo 里第一辆车恒有报价,其余按未入场
|
|
79
|
+
const list = [...this.vehicles.values()];
|
|
80
|
+
return { bound: list.map((v, i) => ({ ...v, parked: i === 0 })) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async listPaymentRecords(_q: ParkingQueryContext, page: number, pageSize: number): Promise<ParkingRecordPage> {
|
|
84
|
+
const start = (page - 1) * pageSize;
|
|
85
|
+
const items = this.records.slice(start, start + pageSize);
|
|
86
|
+
return { items, total: this.records.length, page, pageSize, hasMore: start + pageSize < this.records.length };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getPaymentRecordDetail(_q: ParkingQueryContext, recordRef: string): Promise<ParkingPaymentRecord> {
|
|
90
|
+
const record = this.records.find((r) => r.recordRef === recordRef);
|
|
91
|
+
if (!record) throw new Error(`mock:记录不存在 ${recordRef}`);
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getInvoiceSettings(): Promise<{ parkingInvoiceEnabled: boolean }> {
|
|
96
|
+
return { parkingInvoiceEnabled: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async listInvoiceableOrders(_q: ParkingQueryContext, page: number, pageSize: number): Promise<ParkingRecordPage> {
|
|
100
|
+
const all = this.records.filter((r) => r.orderCompleted === true);
|
|
101
|
+
const start = (page - 1) * pageSize;
|
|
102
|
+
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize, hasMore: start + pageSize < all.length };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async queryParkingInfo(_q: ParkingQueryContext, kind: ParkingInfo['kind']): Promise<ParkingInfo> {
|
|
106
|
+
return {
|
|
107
|
+
kind,
|
|
108
|
+
title: '示例停车场',
|
|
109
|
+
entries: [
|
|
110
|
+
{ label: '剩余车位', value: '128' },
|
|
111
|
+
{ label: '首小时费率', value: '免费' },
|
|
112
|
+
{ label: '会员权益', value: '会员 95 折' },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async queryApplicableBenefits(_q: ParkingQueryContext, _plateNo: string): Promise<readonly ParkingBenefit[]> {
|
|
118
|
+
return DEMO_BENEFITS;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async locateVehicle(_q: ParkingQueryContext, _plateNo: string): Promise<ParkingLocation | null> {
|
|
122
|
+
return { floorName: 'B1', zoneName: 'A 区', spaceNo: 'A-128', walkingRoute: '电梯左转 50 米' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ===== 支付三步链(mock:reconcile 恒 PAID 收口) =====
|
|
126
|
+
|
|
127
|
+
async createOrder(_q: ParkingQueryContext, quoteRef: string): Promise<{ payOrderId?: string; paymentRef: string }> {
|
|
128
|
+
return { payOrderId: `demo:pay:${quoteRef}`, paymentRef: `demo:payment:${quoteRef}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getOrderInfo(_q: ParkingQueryContext, _paymentRef: string): Promise<{ orderInfo?: string; channel: string; payableFeeCents: number | undefined }> {
|
|
132
|
+
return { orderInfo: 'demo-order-info', channel: '50', payableFeeCents: 1500 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async getPaymentSign(_q: ParkingQueryContext, _paymentRef: string): Promise<{ orderInfo: string }> {
|
|
136
|
+
return { orderInfo: 'demo-order-info' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async uploadResult(): Promise<void> {
|
|
140
|
+
// mock:仅上报不作收口依据
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async reconcile(_q: ParkingQueryContext, _paymentRef: string): Promise<'PAID' | 'PAYING' | 'CANCELLED' | 'UNKNOWN'> {
|
|
144
|
+
return 'PAID';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async reprice(q: ParkingQueryContext, quoteRef: string, benefitRefs: readonly string[]): Promise<QuoteSnapshot> {
|
|
148
|
+
const base = demoQuote(q, 'demo-plate');
|
|
149
|
+
const discount = DEMO_BENEFITS.filter((b) => benefitRefs.includes(b.benefitRef))
|
|
150
|
+
.reduce((sum, b) => sum + (b.estimatedDiscountCents ?? 0), 0);
|
|
151
|
+
return {
|
|
152
|
+
...base,
|
|
153
|
+
quoteRef: `${quoteRef}#r${Date.now()}`,
|
|
154
|
+
discountCents: discount,
|
|
155
|
+
payableCents: Math.max(0, base.totalCents - discount),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async applyInvoice(_q: ParkingQueryContext, recordRef: string): Promise<void> {
|
|
160
|
+
if (!this.records.some((r) => r.recordRef === recordRef)) {
|
|
161
|
+
throw new Error(`mock:开票记录不存在 ${recordRef}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ===== 车辆管理 =====
|
|
166
|
+
|
|
167
|
+
async bindVehicle(_q: ParkingQueryContext, plateNo: string): Promise<ParkingVehicle> {
|
|
168
|
+
const vehicle = { vehicleRef: `demo:vehicle:${plateNo}`, plateNo, boundAt: now() };
|
|
169
|
+
this.vehicles.set(vehicle.vehicleRef, vehicle);
|
|
170
|
+
return vehicle;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async unbindVehicle(_q: ParkingQueryContext, input: { plateNo?: string; vehicleRef?: string }): Promise<void> {
|
|
174
|
+
if (input.vehicleRef) this.vehicles.delete(input.vehicleRef);
|
|
175
|
+
else if (input.plateNo) {
|
|
176
|
+
for (const [ref, v] of this.vehicles) {
|
|
177
|
+
if (v.plateNo === input.plateNo) this.vehicles.delete(ref);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
}
|