@tbox.cn/app-provider-mock 0.2.0 → 0.3.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/README.md +38 -0
- package/dist/server/index.d.ts +41 -38
- package/dist/server/index.js +258 -130
- package/package.json +6 -5
- package/schemas/mall-info.config.json +11 -0
- package/schemas/member.config.json +13 -0
- package/schemas/parking.config.json +19 -0
- package/schemas/promotion.config.json +13 -0
- package/schemas/shopping.config.json +12 -0
- package/src/server/config.ts +26 -0
- package/src/server/index.ts +16 -4
- package/src/server/mall-info.ts +27 -12
- package/src/server/member.ts +45 -17
- package/src/server/parking.ts +78 -49
- package/src/server/promotion.ts +77 -56
- package/src/server/shopping.ts +41 -3
- package/tbox.module.json +34 -17
- package/tests/config-schema.test.ts +92 -0
- package/tests/integrations-e2e.test.ts +117 -2
- package/tests/mall-info.test.ts +11 -2
- package/tests/mock-impl.test.ts +103 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Mock 营销域实例配置",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"couponTitle": { "type": "string", "title": "券标题(列表/详情/资产同源)", "default": "满 100 减 20 元券" },
|
|
7
|
+
"couponValueCents": { "type": "integer", "minimum": 0, "title": "券面额(分)", "default": 2000 },
|
|
8
|
+
"couponThresholdCents": { "type": "integer", "minimum": 0, "title": "券门槛(分)", "default": 10000 },
|
|
9
|
+
"couponValidityDays": { "type": "integer", "minimum": 1, "maximum": 365, "title": "券有效期(天)", "default": 30 },
|
|
10
|
+
"grouponEnabled": { "type": "boolean", "title": "团购演示项开关(关闭 = 优惠列表仅券)", "default": true },
|
|
11
|
+
"activityTitle": { "type": "string", "title": "活动标题", "default": "示例商场周年庆" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Mock 导购域实例配置",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"extraShopName": { "type": "string", "title": "追加门店名(空串 = 不追加)", "default": "" },
|
|
7
|
+
"extraShopCategory": { "type": "string", "title": "追加门店类目", "default": "餐饮" },
|
|
8
|
+
"extraShopFloor": { "type": "string", "enum": ["B1", "1 楼", "2 楼", "3 楼", "4 楼"], "title": "追加门店楼层", "default": "3 楼" },
|
|
9
|
+
"extraShopUnit": { "type": "string", "title": "追加门店铺位号", "default": "304" },
|
|
10
|
+
"extraShopOpen": { "type": "boolean", "title": "追加门店营业状态", "default": true }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock 实例配置快照(fail-soft 单源):provider-mock 全部 config 消费的唯一读取面。
|
|
3
|
+
* - 只透出 defaults 键(构造性防死字段);defaults 键 ≡ 被消费键;
|
|
4
|
+
* - 非法回退缺省:类型族不符 / NaN / intKeys 键非整数 / 空串(统一「空串 = 缺省」语义);
|
|
5
|
+
* - intKeys 显式列举而非按 default 值推断(JS 中 30.0 === 30,推断式必误拒分式经纬度);
|
|
6
|
+
* - schema 是表单引导非门禁——守卫在消费侧(五层浅合并由平台侧完成,本层零合并逻辑)。
|
|
7
|
+
* 双锁测试(tests/config-schema.test.ts)保证 schema.properties ≡ DEFAULTS 键集与 default 值。
|
|
8
|
+
*/
|
|
9
|
+
export type CfgDefaults = Readonly<Record<string, string | number | boolean>>;
|
|
10
|
+
|
|
11
|
+
export function resolveConfig<T extends CfgDefaults>(
|
|
12
|
+
raw: unknown,
|
|
13
|
+
defaults: T,
|
|
14
|
+
intKeys: readonly string[] = [],
|
|
15
|
+
): T {
|
|
16
|
+
const src = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
17
|
+
const out: Record<string, string | number | boolean> = {};
|
|
18
|
+
for (const [key, fallback] of Object.entries(defaults)) {
|
|
19
|
+
const value = src[key];
|
|
20
|
+
const ok = typeof value === typeof fallback
|
|
21
|
+
&& (typeof value !== 'number' || (Number.isFinite(value) && (!intKeys.includes(key) || Number.isInteger(value))))
|
|
22
|
+
&& (typeof value !== 'string' || value !== '');
|
|
23
|
+
out[key] = ok ? (value as string | number | boolean) : fallback;
|
|
24
|
+
}
|
|
25
|
+
return out as T;
|
|
26
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* (catalog 空 + shop/category/directory 纯 mock 数据);assistance/navigation 本地知识库域
|
|
13
13
|
* 归模块本地 adapter,不属 provider 供给面。
|
|
14
14
|
*
|
|
15
|
+
* config 消费:五域工厂透传 input.config → 各 impl 构造期 fail-soft 快照
|
|
16
|
+
* (src/server/config.ts;键集/缺省双锁 tests/config-schema.test.ts);零配置 = 零行为变化。
|
|
17
|
+
*
|
|
18
|
+
* 隐式默认单实例(零拓扑部署兜底):register 期经 ctx.integrations.declareProviderInstances
|
|
19
|
+
* 声明 mall-demo——显式 instances/defaultInstanceId 恒赢(融合规则见 contracts-mall
|
|
20
|
+
* mallControlPlaneOf);值与 .real-env mock 档演示语义一致(星河 Mock 小镇 / mock-demo-app)。
|
|
21
|
+
*
|
|
15
22
|
* 入会体验闭环(C5)追加两件:
|
|
16
23
|
* - scoped mock 换码器(ctx.authExchanges):固定 demo-user、GUEST/MEMBER 双形态对齐
|
|
17
24
|
* 真实厂商换码协议(潜客 token=''+openid ↔ 会员 token+memberId)——mock 档冷登走与
|
|
@@ -49,9 +56,9 @@ export { MockShoppingServiceImpl } from './shopping';
|
|
|
49
56
|
export { MOCK_MALL_INFO_PROVIDER, MOCK_MALL_INFO_IMPL, mockMallInfoFactory } from './mall-info';
|
|
50
57
|
export { createMockEnrollRouter } from './mock-enroll-route';
|
|
51
58
|
|
|
52
|
-
const memberFactory: ServiceAdapterFactory = () => new MockMemberServiceImpl();
|
|
53
|
-
const parkingFactory: ServiceAdapterFactory = (input) => new MockParkingServiceImpl(input.instanceId || 'mall-demo');
|
|
54
|
-
const promotionFactory: ServiceAdapterFactory = () => new MockPromotionServiceImpl();
|
|
59
|
+
const memberFactory: ServiceAdapterFactory = (input) => new MockMemberServiceImpl({}, input.config);
|
|
60
|
+
const parkingFactory: ServiceAdapterFactory = (input) => new MockParkingServiceImpl(input.instanceId || 'mall-demo', input.config);
|
|
61
|
+
const promotionFactory: ServiceAdapterFactory = (input) => new MockPromotionServiceImpl(input.config);
|
|
55
62
|
/** shopping-guide:四槽分支(017 槽位原子化)——MockShoppingServiceImpl 实现全部四 Port,
|
|
56
63
|
* 结构类型满足任一槽产物(catalog 槽 = mock 空商品数据,对齐真实档无商品端点形态)。 */
|
|
57
64
|
const shoppingFactory: ServiceAdapterFactory = (input) => {
|
|
@@ -63,7 +70,7 @@ const shoppingFactory: ServiceAdapterFactory = (input) => {
|
|
|
63
70
|
) {
|
|
64
71
|
throw new Error(`mock guide 工厂不供给槽:${input.service}`);
|
|
65
72
|
}
|
|
66
|
-
return new MockShoppingServiceImpl();
|
|
73
|
+
return new MockShoppingServiceImpl(undefined, input.config);
|
|
67
74
|
};
|
|
68
75
|
/** mall.info:动态商场演示源(任意合法 mallId → 确定性别名;对齐真实厂商 mall-info adapter 通用形态) */
|
|
69
76
|
const mallInfoFactory: ServiceAdapterFactory = (input) => mockMallInfoFactory(input);
|
|
@@ -134,6 +141,11 @@ export const serverModule = {
|
|
|
134
141
|
},
|
|
135
142
|
cards,
|
|
136
143
|
register(ctx: ServerContext): void {
|
|
144
|
+
// 隐式默认单实例声明(零拓扑部署兜底——显式 instances/defaultInstanceId 恒赢;
|
|
145
|
+
// 融合规则见 contracts-mall mallControlPlaneOf。?. 兼容旧 SDK:缺 API 时 no-op 降级)
|
|
146
|
+
ctx.integrations?.declareProviderInstances?.('mock', [
|
|
147
|
+
{ id: 'mall-demo', name: '星河 Mock 小镇', attributes: { miniAppId: 'mock-demo-app' } },
|
|
148
|
+
]);
|
|
137
149
|
// adapter 注册(v2 双键:provider 厂商名 + implementation)
|
|
138
150
|
ctx.integrations?.registerAdapter(MOCK_PROVIDER, MOCK_MEMBER_IMPL, memberFactory);
|
|
139
151
|
ctx.integrations?.registerAdapter(MOCK_PROVIDER, MOCK_PARKING_IMPL, parkingFactory);
|
package/src/server/mall-info.ts
CHANGED
|
@@ -9,26 +9,41 @@
|
|
|
9
9
|
* - mallId 形状守卫(/^[\w-]{1,32}$/):畸形 ID 直接返回 undefined(S4 DoS 防线——
|
|
10
10
|
* 记录≠校验教训:请求形状断言在 provider adapter 单测锁死)
|
|
11
11
|
* - fail-soft 契约恒满足:本实现无 IO,不可能失败——契约面与真实档保持一致
|
|
12
|
+
* - config 消费:MALL_INFO_CFG_DEFAULTS 为契约单源(name 空串 = 动态别名回退;
|
|
13
|
+
* 双锁测试 tests/config-schema.test.ts)
|
|
12
14
|
*/
|
|
15
|
+
import { resolveConfig, type CfgDefaults } from './config';
|
|
13
16
|
import type { MallInfo, MallInfoPort } from '@tbox.cn/app-contracts-mall';
|
|
14
17
|
import type { ServiceAdapterFactory } from '@tbox.cn/app-sdk/server';
|
|
15
18
|
|
|
16
19
|
export const MOCK_MALL_INFO_PROVIDER = 'mock';
|
|
17
20
|
export const MOCK_MALL_INFO_IMPL = 'mock-mall-info@1';
|
|
18
21
|
|
|
22
|
+
/** config 契约单源(键集/缺省/整数约束;lat/lon 分式合法——不进 intKeys) */
|
|
23
|
+
export const MALL_INFO_CFG_DEFAULTS = {
|
|
24
|
+
name: '', // '' 哨兵 = 动态别名回退
|
|
25
|
+
address: '示例商场地址(demo)',
|
|
26
|
+
latitude: 30.0,
|
|
27
|
+
longitude: 120.0,
|
|
28
|
+
} as const satisfies CfgDefaults;
|
|
29
|
+
export const MALL_INFO_CFG_INT_KEYS: readonly string[] = [];
|
|
30
|
+
|
|
19
31
|
/** mallId 形状守卫(防注入类字符;畸形 → undefined——与真实厂商 adapter 同规则) */
|
|
20
32
|
const MALL_ID_RE = /^[\w-]{1,32}$/;
|
|
21
33
|
|
|
22
34
|
/** mock mall.info adapter 工厂(对齐真实厂商 adapter 工厂形态) */
|
|
23
|
-
export const mockMallInfoFactory: ServiceAdapterFactory = (): MallInfoPort =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
mallId
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
+
export const mockMallInfoFactory: ServiceAdapterFactory = (input): MallInfoPort => {
|
|
36
|
+
const cfg = resolveConfig(input.config, MALL_INFO_CFG_DEFAULTS, MALL_INFO_CFG_INT_KEYS);
|
|
37
|
+
return {
|
|
38
|
+
async getMallInfo(mallId: string): Promise<MallInfo | undefined> {
|
|
39
|
+
if (!MALL_ID_RE.test(mallId)) return undefined;
|
|
40
|
+
return {
|
|
41
|
+
mallId,
|
|
42
|
+
name: cfg.name !== '' ? cfg.name : `示例商场(${mallId})`,
|
|
43
|
+
address: cfg.address,
|
|
44
|
+
latitude: cfg.latitude,
|
|
45
|
+
longitude: cfg.longitude,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
package/src/server/member.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Mock member 实现(provider-mock 自持——R3-1 裁决:provider 不得依赖业务模块,实现自持
|
|
3
3
|
* 而非包装 module-member InMemory)。演示面最小集:开户/卡面/积分/流水/入会闭环
|
|
4
4
|
* (demo ref 恒 `demo:` 前缀,厂商中立)。
|
|
5
|
+
* config 消费:MEMBER_CFG_DEFAULTS 为契约单源(fail-soft 快照;双锁测试 tests/config-schema.test.ts)。
|
|
5
6
|
*/
|
|
7
|
+
import { resolveConfig, type CfgDefaults } from './config';
|
|
6
8
|
import type {
|
|
7
9
|
MemberAccountPort,
|
|
8
10
|
MemberAccountSnapshot,
|
|
@@ -27,9 +29,16 @@ export type MockMemberService = MemberAccountPort &
|
|
|
27
29
|
MemberBenefitsPort &
|
|
28
30
|
MemberEnrollmentPort;
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
/** config 契约单源(键集/缺省/整数约束;tests/config-schema.test.ts 双锁) */
|
|
33
|
+
export const MEMBER_CFG_DEFAULTS = {
|
|
34
|
+
memberName: '示例会员',
|
|
35
|
+
cardName: '示例会员卡',
|
|
36
|
+
levelName: '', // '' 哨兵 = 不展示等级区(MemberCardSnapshot.levelName 可选位)
|
|
37
|
+
initialPoints: 300,
|
|
38
|
+
enrollUrl: 'alipays://platformapi/startapp?appId=demo&page=v/index/index',
|
|
39
|
+
loginUrl: 'alipays://platformapi/startapp?appId=demo&page=v/login/index',
|
|
40
|
+
} as const satisfies CfgDefaults;
|
|
41
|
+
export const MEMBER_CFG_INT_KEYS = ['initialPoints'];
|
|
33
42
|
|
|
34
43
|
/**
|
|
35
44
|
* Mock member 状态单源(模块级共享):factory 每请求新建实例,但状态必须跨实例共享——
|
|
@@ -44,11 +53,11 @@ const mockMemberState = {
|
|
|
44
53
|
/** mock 壳侧登录用户(换码器固定身份;mock 语义 = 单 demo 用户/部署) */
|
|
45
54
|
export const MOCK_SHELL_USER_ID = 'demo-user';
|
|
46
55
|
|
|
47
|
-
/** 入会落地(幂等):enroll 路由/enroll()
|
|
56
|
+
/** 入会落地(幂等):enroll 路由/enroll() 共用;余额不写种子——查询侧按实例配置惰性读
|
|
57
|
+
*(默认 300,见 currentBalanceOf);登录成功即清除登出态 */
|
|
48
58
|
export function enrollMockMember(userId: string): void {
|
|
49
59
|
mockMemberState.enrolled.add(userId);
|
|
50
60
|
mockMemberState.loggedOut.delete(userId);
|
|
51
|
-
if (!mockMemberState.balances.has(userId)) mockMemberState.balances.set(userId, 300);
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export function isMockMemberEnrolled(userId: string): boolean {
|
|
@@ -84,12 +93,23 @@ export class MockMemberServiceImpl implements MockMemberService {
|
|
|
84
93
|
* 透传素材,mock 出厂面恒配 demo 值)。demo 值厂商中立(appId=demo),点击在壳内
|
|
85
94
|
* navigateTo 失败即无害降级。
|
|
86
95
|
*/
|
|
87
|
-
readonly enrollUrl
|
|
96
|
+
readonly enrollUrl: string;
|
|
88
97
|
/** 登录页深链(同上,可选透传素材;供未登录引导卡跳转)。 */
|
|
89
|
-
readonly loginUrl
|
|
98
|
+
readonly loginUrl: string;
|
|
99
|
+
|
|
100
|
+
/** 配置快照(fail-soft,构造期一次解析;键集 ≡ MEMBER_CFG_DEFAULTS) */
|
|
101
|
+
private readonly cfg: typeof MEMBER_CFG_DEFAULTS;
|
|
102
|
+
/** 积分流水(金额随 initialPoints 派生——与余额同源) */
|
|
103
|
+
private readonly usage: PointUsageRecord[];
|
|
90
104
|
|
|
91
105
|
/** 默认访客(无种子):demo-user 初始 not_enrolled,经入会闭环翻为 active */
|
|
92
|
-
constructor(initial: Record<string, number> = {}) {
|
|
106
|
+
constructor(initial: Record<string, number> = {}, config: unknown = {}) {
|
|
107
|
+
this.cfg = resolveConfig(config, MEMBER_CFG_DEFAULTS, MEMBER_CFG_INT_KEYS);
|
|
108
|
+
this.enrollUrl = this.cfg.enrollUrl;
|
|
109
|
+
this.loginUrl = this.cfg.loginUrl;
|
|
110
|
+
this.usage = [
|
|
111
|
+
{ usageRecordRef: 'demo:usage:1', title: '开卡礼', amount: `+${String(this.cfg.initialPoints)}`, usedAt: new Date().toISOString(), description: 'mock 演示流水' },
|
|
112
|
+
];
|
|
93
113
|
for (const [userId, balance] of Object.entries(initial)) {
|
|
94
114
|
mockMemberState.enrolled.add(userId);
|
|
95
115
|
mockMemberState.balances.set(userId, balance);
|
|
@@ -107,30 +127,31 @@ export class MockMemberServiceImpl implements MockMemberService {
|
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
async getProfile(_userId: string): Promise<MemberProfileSnapshot> {
|
|
110
|
-
return { labels: [{ labelRef: 'demo:label:1', name:
|
|
130
|
+
return { labels: [{ labelRef: 'demo:label:1', name: this.cfg.memberName }], observedAt: new Date().toISOString() };
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
async getCard(userId: string): Promise<MemberCardSnapshot | null> {
|
|
114
134
|
if (!isMockMemberActive(userId)) return null;
|
|
115
135
|
return {
|
|
116
136
|
cardRef: `demo:card:${userId}`,
|
|
117
|
-
cardDisplay:
|
|
137
|
+
cardDisplay: this.cfg.cardName,
|
|
118
138
|
status: 'active',
|
|
139
|
+
...(this.cfg.levelName !== '' ? { levelName: this.cfg.levelName } : {}),
|
|
119
140
|
observedAt: new Date().toISOString(),
|
|
120
141
|
};
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
async queryPoints(userId: string): Promise<Points> {
|
|
124
|
-
return { userId, balance:
|
|
145
|
+
return { userId, balance: this.currentBalanceOf(userId), membershipLabel: this.cfg.cardName };
|
|
125
146
|
}
|
|
126
147
|
|
|
127
|
-
async getBenefits(
|
|
128
|
-
return { availablePoints:
|
|
148
|
+
async getBenefits(userId: string): Promise<MemberBenefitsSnapshot> {
|
|
149
|
+
return { availablePoints: this.currentBalanceOf(userId), levelName: '示例等级', observedAt: new Date().toISOString() };
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
async listPointUsage(_userId: string, page: number, pageSize: number) {
|
|
132
153
|
const start = (page - 1) * pageSize;
|
|
133
|
-
return { items:
|
|
154
|
+
return { items: this.usage.slice(start, start + pageSize), total: this.usage.length };
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
async queryDiscount(): Promise<DiscountRule | undefined> {
|
|
@@ -138,22 +159,29 @@ export class MockMemberServiceImpl implements MockMemberService {
|
|
|
138
159
|
}
|
|
139
160
|
|
|
140
161
|
async deductPoints(userId: string, amount: number, _key: string): Promise<boolean> {
|
|
141
|
-
const balance =
|
|
162
|
+
const balance = this.currentBalanceOf(userId);
|
|
142
163
|
if (balance < amount) return false;
|
|
143
164
|
mockMemberState.balances.set(userId, balance - amount);
|
|
144
165
|
return true;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
async refundPoints(userId: string, amount: number, _key: string): Promise<boolean> {
|
|
148
|
-
mockMemberState.balances.set(userId,
|
|
169
|
+
mockMemberState.balances.set(userId, this.currentBalanceOf(userId) + amount);
|
|
149
170
|
return true;
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
async awardPoints(userId: string, amount: number): Promise<boolean> {
|
|
153
|
-
mockMemberState.balances.set(userId,
|
|
174
|
+
mockMemberState.balances.set(userId, this.currentBalanceOf(userId) + amount);
|
|
154
175
|
return true;
|
|
155
176
|
}
|
|
156
177
|
|
|
178
|
+
/** 余额单源:显式增减覆盖值 ??(已入会 ? 配置初值 : 0)——入会不写种子(惰性读) */
|
|
179
|
+
private currentBalanceOf(userId: string): number {
|
|
180
|
+
const override = mockMemberState.balances.get(userId);
|
|
181
|
+
if (override !== undefined) return override;
|
|
182
|
+
return isMockMemberEnrolled(userId) ? this.cfg.initialPoints : 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
157
185
|
async enroll(input: MemberEnrollmentInput): Promise<MemberEnrollmentResult> {
|
|
158
186
|
enrollMockMember(input.userId);
|
|
159
187
|
return { status: 'active', memberRef: `demo:member:${input.userId}` };
|
package/src/server/parking.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Mock parking 实现(provider-mock 自持——R3-1 裁决)。演示面最小集:报价/车辆/记录/
|
|
3
3
|
* 综合信息/权益/寻车 + 三步支付链 mock 闭环(reconcile 恒 PAID 收口)。demo ref 恒
|
|
4
4
|
* `demo:` 前缀,厂商中立;车牌未提供时以演示车(demo-plate)兜底。
|
|
5
|
+
* config 消费:PARKING_CFG_DEFAULTS 为契约单源(fail-soft 快照;双锁测试 tests/config-schema.test.ts)。
|
|
5
6
|
*/
|
|
7
|
+
import { resolveConfig, type CfgDefaults } from './config';
|
|
6
8
|
import type {
|
|
7
9
|
ParkingBenefit,
|
|
8
10
|
ParkingInfo,
|
|
@@ -21,56 +23,83 @@ export type MockParkingService = ParkingQueryPort & ParkingPaymentPort & Parking
|
|
|
21
23
|
|
|
22
24
|
const now = () => new Date().toISOString();
|
|
23
25
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
}
|
|
26
|
+
/** config 契约单源(键集/缺省/整数约束;tests/config-schema.test.ts 双锁) */
|
|
27
|
+
export const PARKING_CFG_DEFAULTS = {
|
|
28
|
+
parkName: '示例停车场',
|
|
29
|
+
feeName: '停车费(3 小时)',
|
|
30
|
+
feeCents: 1500,
|
|
31
|
+
remainingSpaces: 128,
|
|
32
|
+
firstHourRate: '免费',
|
|
33
|
+
memberBenefitEnabled: true,
|
|
34
|
+
memberBenefitDiscountCents: 75,
|
|
35
|
+
pointsBenefitEnabled: true,
|
|
36
|
+
invoiceEnabled: true,
|
|
37
|
+
vehicleFloor: 'B1',
|
|
38
|
+
vehicleZone: 'A 区',
|
|
39
|
+
vehicleSpaceNo: 'A-128',
|
|
40
|
+
} as const satisfies CfgDefaults;
|
|
41
|
+
export const PARKING_CFG_INT_KEYS = ['feeCents', 'remainingSpaces', 'memberBenefitDiscountCents'];
|
|
49
42
|
|
|
50
43
|
export class MockParkingServiceImpl implements MockParkingService {
|
|
51
44
|
private vehicles = new Map<string, ParkingVehicle>();
|
|
52
|
-
private records: ParkingPaymentRecord[]
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
paidAt: now(),
|
|
58
|
-
status: 'PAID',
|
|
59
|
-
parkName: '示例停车场',
|
|
60
|
-
orderCompleted: true,
|
|
61
|
-
},
|
|
62
|
-
];
|
|
63
|
-
|
|
64
|
-
constructor(mallId = 'mall-demo') {
|
|
45
|
+
private records: ParkingPaymentRecord[];
|
|
46
|
+
/** 配置快照(fail-soft,构造期一次解析;键集 ≡ PARKING_CFG_DEFAULTS) */
|
|
47
|
+
private readonly cfg: typeof PARKING_CFG_DEFAULTS;
|
|
48
|
+
|
|
49
|
+
constructor(mallId = 'mall-demo', config: unknown = {}) {
|
|
65
50
|
this.mallId = mallId;
|
|
66
51
|
void this.mallId;
|
|
52
|
+
this.cfg = resolveConfig(config, PARKING_CFG_DEFAULTS, PARKING_CFG_INT_KEYS);
|
|
53
|
+
this.records = [
|
|
54
|
+
{
|
|
55
|
+
recordRef: 'demo:record:1',
|
|
56
|
+
plateNo: 'demo-plate',
|
|
57
|
+
amountCents: this.cfg.feeCents,
|
|
58
|
+
paidAt: now(),
|
|
59
|
+
status: 'PAID',
|
|
60
|
+
parkName: this.cfg.parkName,
|
|
61
|
+
orderCompleted: true,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
private mallId: string;
|
|
70
67
|
|
|
68
|
+
/**
|
|
69
|
+
* kind 必须下发:客户端按它分流(discount = 自动生效、不进勾选区;points = 积分入口),
|
|
70
|
+
* 服务端 projectPaymentOptions 也只认 kind==='points'。缺省时整套分流静默失效——
|
|
71
|
+
* 等级权益会退化成一个可勾选项,积分入口则从不出现。
|
|
72
|
+
*/
|
|
73
|
+
private benefits(): ParkingBenefit[] {
|
|
74
|
+
const benefits: ParkingBenefit[] = [];
|
|
75
|
+
if (this.cfg.memberBenefitEnabled) {
|
|
76
|
+
benefits.push({ benefitRef: 'demo:benefit:member-discount', title: '会员 95 折', description: 'mock 演示权益', kind: 'discount', autoSelected: true, estimatedDiscountCents: this.cfg.memberBenefitDiscountCents });
|
|
77
|
+
}
|
|
78
|
+
if (this.cfg.pointsBenefitEnabled) {
|
|
79
|
+
benefits.push({ benefitRef: 'demo:benefit:points-offset', title: '积分抵 5 元', description: 'mock 演示权益', kind: 'points', autoSelected: false, estimatedDiscountCents: 500, requiredPoints: 500, previewPayableCents: 1000 });
|
|
80
|
+
}
|
|
81
|
+
return benefits;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private quote(q: ParkingQueryContext, plateNo: string): QuoteSnapshot {
|
|
85
|
+
return {
|
|
86
|
+
quoteRef: `demo:quote:${plateNo}`,
|
|
87
|
+
plateNo,
|
|
88
|
+
mallId: q.scope.mallId ?? 'demo',
|
|
89
|
+
entryAt: now(),
|
|
90
|
+
feeItems: [{ name: this.cfg.feeName, amountCents: this.cfg.feeCents }],
|
|
91
|
+
totalCents: this.cfg.feeCents,
|
|
92
|
+
discountCents: 0,
|
|
93
|
+
payableCents: this.cfg.feeCents,
|
|
94
|
+
appliedBenefits: [],
|
|
95
|
+
revision: 1,
|
|
96
|
+
paymentState: 'UNPAID',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
71
100
|
async queryFee(q: ParkingQueryContext, input: { plateNo?: string; vehicleRef?: string }): Promise<readonly QuoteSnapshot[]> {
|
|
72
101
|
const plateNo = input.plateNo ?? (input.vehicleRef ? this.vehicles.get(input.vehicleRef)?.plateNo : undefined) ?? 'demo-plate';
|
|
73
|
-
const quote =
|
|
102
|
+
const quote = this.quote(q, plateNo);
|
|
74
103
|
return quote ? [quote] : [];
|
|
75
104
|
}
|
|
76
105
|
|
|
@@ -93,7 +122,7 @@ export class MockParkingServiceImpl implements MockParkingService {
|
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
async getInvoiceSettings(): Promise<{ parkingInvoiceEnabled: boolean }> {
|
|
96
|
-
return { parkingInvoiceEnabled:
|
|
125
|
+
return { parkingInvoiceEnabled: this.cfg.invoiceEnabled };
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
async listInvoiceableOrders(_q: ParkingQueryContext, page: number, pageSize: number): Promise<ParkingRecordPage> {
|
|
@@ -105,21 +134,21 @@ export class MockParkingServiceImpl implements MockParkingService {
|
|
|
105
134
|
async queryParkingInfo(_q: ParkingQueryContext, kind: ParkingInfo['kind']): Promise<ParkingInfo> {
|
|
106
135
|
return {
|
|
107
136
|
kind,
|
|
108
|
-
title:
|
|
137
|
+
title: this.cfg.parkName,
|
|
109
138
|
entries: [
|
|
110
|
-
{ label: '剩余车位', value:
|
|
111
|
-
{ label: '首小时费率', value:
|
|
112
|
-
{ label: '会员权益', value: '会员 95 折' },
|
|
139
|
+
{ label: '剩余车位', value: String(this.cfg.remainingSpaces) },
|
|
140
|
+
{ label: '首小时费率', value: this.cfg.firstHourRate },
|
|
141
|
+
{ label: '会员权益', value: this.cfg.memberBenefitEnabled ? '会员 95 折' : '—' },
|
|
113
142
|
],
|
|
114
143
|
};
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
async queryApplicableBenefits(_q: ParkingQueryContext, _plateNo: string): Promise<readonly ParkingBenefit[]> {
|
|
118
|
-
return
|
|
147
|
+
return this.benefits();
|
|
119
148
|
}
|
|
120
149
|
|
|
121
150
|
async locateVehicle(_q: ParkingQueryContext, _plateNo: string): Promise<ParkingLocation | null> {
|
|
122
|
-
return { floorName:
|
|
151
|
+
return { floorName: this.cfg.vehicleFloor, zoneName: this.cfg.vehicleZone, spaceNo: this.cfg.vehicleSpaceNo, walkingRoute: '电梯左转 50 米' };
|
|
123
152
|
}
|
|
124
153
|
|
|
125
154
|
// ===== 支付三步链(mock:reconcile 恒 PAID 收口) =====
|
|
@@ -129,7 +158,7 @@ export class MockParkingServiceImpl implements MockParkingService {
|
|
|
129
158
|
}
|
|
130
159
|
|
|
131
160
|
async getOrderInfo(_q: ParkingQueryContext, _paymentRef: string): Promise<{ orderInfo?: string; channel: string; payableFeeCents: number | undefined }> {
|
|
132
|
-
return { orderInfo: 'demo-order-info', channel: '50', payableFeeCents:
|
|
161
|
+
return { orderInfo: 'demo-order-info', channel: '50', payableFeeCents: this.cfg.feeCents };
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
async getPaymentSign(_q: ParkingQueryContext, _paymentRef: string): Promise<{ orderInfo: string }> {
|
|
@@ -145,8 +174,8 @@ export class MockParkingServiceImpl implements MockParkingService {
|
|
|
145
174
|
}
|
|
146
175
|
|
|
147
176
|
async reprice(q: ParkingQueryContext, quoteRef: string, benefitRefs: readonly string[]): Promise<QuoteSnapshot> {
|
|
148
|
-
const base =
|
|
149
|
-
const discount =
|
|
177
|
+
const base = this.quote(q, 'demo-plate');
|
|
178
|
+
const discount = this.benefits().filter((b) => benefitRefs.includes(b.benefitRef))
|
|
150
179
|
.reduce((sum, b) => sum + (b.estimatedDiscountCents ?? 0), 0);
|
|
151
180
|
return {
|
|
152
181
|
...base,
|