feat(companion): 全面扩展同城生活陪伴体系(陪吃·陪喝·陪玩),完成TDD测试与安全核销闭环
这个提交包含在:
@@ -164,4 +164,33 @@ describe('ActivityService (TDD 测试套件: 活动项目与4位核销码)', ()
|
||||
}).toThrow('无权核销该活动的核销码');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 同城线下陪玩·陪吃·陪喝组局活动', () => {
|
||||
it('应成功发起线下同城火锅探店AA组局活动并完成现场核销', () => {
|
||||
const mealAct = activityService.createActivity({
|
||||
creatorId: 1003,
|
||||
title: '【周末同城】三里屯火锅探店饭搭子4人AA局',
|
||||
gameCategory: '同城陪吃',
|
||||
location: '北京市朝阳区三里屯',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 5000,
|
||||
maxParticipants: 4,
|
||||
});
|
||||
|
||||
expect(mealAct.location).toBe('北京市朝阳区三里屯');
|
||||
const p = activityService.joinActivity(mealAct.id, {
|
||||
userId: 2005,
|
||||
payAmountCents: 5000,
|
||||
});
|
||||
expect(p.verifyCode).toMatch(/^\d{4}$/);
|
||||
|
||||
const check = activityService.verifyCheckin({
|
||||
activityId: mealAct.id,
|
||||
operatorId: 1003,
|
||||
verifyCode: p.verifyCode,
|
||||
});
|
||||
expect(check.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
CompanionTrack,
|
||||
ExpenseBurdenType,
|
||||
ServiceCategoryManager,
|
||||
CreateServiceItemDto
|
||||
} from '../src/core/companion/service-category';
|
||||
|
||||
describe('CompanionCategoryManager (TDD 测试套件: 陪玩·陪吃·陪喝品类与安全风控)', () => {
|
||||
let manager: ServiceCategoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ServiceCategoryManager();
|
||||
});
|
||||
|
||||
describe('1. 品类赛道与元数据校验', () => {
|
||||
it('应正确识别并列出包含线上游戏、线下陪玩、陪吃、陪喝的所有赛道', () => {
|
||||
const tracks = manager.getAllTracks();
|
||||
expect(tracks).toContain(CompanionTrack.ONLINE_GAME);
|
||||
expect(tracks).toContain(CompanionTrack.OFFLINE_PLAY);
|
||||
expect(tracks).toContain(CompanionTrack.OFFLINE_MEAL);
|
||||
expect(tracks).toContain(CompanionTrack.OFFLINE_DRINK);
|
||||
expect(tracks.length).toBe(4);
|
||||
});
|
||||
|
||||
it('线上游戏陪玩服务必须包含有效游戏名称', () => {
|
||||
const validGameDto: CreateServiceItemDto = {
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
title: '王者荣耀 · 百星排位包胜率',
|
||||
gameName: '王者荣耀',
|
||||
unitPriceCents: 3500,
|
||||
unit: '小时',
|
||||
};
|
||||
const item = manager.createServiceItem(validGameDto);
|
||||
expect(item.id).toBeDefined();
|
||||
expect(item.track).toBe(CompanionTrack.ONLINE_GAME);
|
||||
|
||||
// 缺失游戏名称应抛出异常
|
||||
expect(() => {
|
||||
manager.createServiceItem({
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
title: '排位上分',
|
||||
unitPriceCents: 3000,
|
||||
unit: '小时',
|
||||
});
|
||||
}).toThrow('线上游戏陪玩服务必须指定具体游戏品类');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 线下陪玩、陪吃、陪喝场所安全风控校验', () => {
|
||||
it('线下陪吃服务必须指定服务城市与公开合法就餐场所', () => {
|
||||
const mealDto: CreateServiceItemDto = {
|
||||
track: CompanionTrack.OFFLINE_MEAL,
|
||||
title: '同城美食探店 · 网红火锅/西餐饭搭子',
|
||||
city: '北京市',
|
||||
venueType: '商场餐饮/品牌连锁餐厅',
|
||||
unitPriceCents: 6000,
|
||||
unit: '小时',
|
||||
};
|
||||
const item = manager.createServiceItem(mealDto);
|
||||
expect(item.id).toBeDefined();
|
||||
expect(item.track).toBe(CompanionTrack.OFFLINE_MEAL);
|
||||
});
|
||||
|
||||
it('线下陪喝服务必须指定服务城市与公开休闲场所', () => {
|
||||
const drinkDto: CreateServiceItemDto = {
|
||||
track: CompanionTrack.OFFLINE_DRINK,
|
||||
title: '下午茶手冲咖啡 · 聊天解压微醺陪伴',
|
||||
city: '上海市',
|
||||
venueType: '星巴克臻选/正规茶室/清吧',
|
||||
unitPriceCents: 5000,
|
||||
unit: '小时',
|
||||
};
|
||||
const item = manager.createServiceItem(drinkDto);
|
||||
expect(item.track).toBe(CompanionTrack.OFFLINE_DRINK);
|
||||
});
|
||||
|
||||
it('线下服务未指定城市应抛出异常', () => {
|
||||
expect(() => {
|
||||
manager.createServiceItem({
|
||||
track: CompanionTrack.OFFLINE_PLAY,
|
||||
title: '剧本杀探案组局',
|
||||
venueType: '正规剧本推理馆',
|
||||
unitPriceCents: 4500,
|
||||
unit: '小时',
|
||||
});
|
||||
}).toThrow('线下陪伴服务必须指定服务城市');
|
||||
});
|
||||
|
||||
it('安全风控规则: 严禁将私人住宅、酒店客房等非公开场所作为履约地点', () => {
|
||||
const unsafeDto: CreateServiceItemDto = {
|
||||
track: CompanionTrack.OFFLINE_MEAL,
|
||||
title: '居家便餐',
|
||||
city: '广州市',
|
||||
venueType: '私人公寓/私人住宅空间',
|
||||
unitPriceCents: 5000,
|
||||
unit: '小时',
|
||||
};
|
||||
expect(() => {
|
||||
manager.createServiceItem(unsafeDto);
|
||||
}).toThrow('安全风控触发: 线下陪伴服务仅限在合法的公开商业场所履约,禁止私人住宅或私密空间');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 消费承担方式 (AA制/客户请客/大神自理)', () => {
|
||||
it('应支持合法的就餐与消费承担方式', () => {
|
||||
const burdenTypes = manager.getExpenseBurdenTypes();
|
||||
expect(burdenTypes).toContain(ExpenseBurdenType.AA_SHARE);
|
||||
expect(burdenTypes).toContain(ExpenseBurdenType.BUYER_TREAT);
|
||||
expect(burdenTypes).toContain(ExpenseBurdenType.SELF_CARE);
|
||||
});
|
||||
});
|
||||
});
|
||||
229
server/tests/companion-order.spec.ts
普通文件
229
server/tests/companion-order.spec.ts
普通文件
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
CompanionOrderService,
|
||||
CreateOrderParams
|
||||
} from '../src/core/order/companion-order.service';
|
||||
import { CompanionTrack, ExpenseBurdenType } from '../src/core/companion/service-category';
|
||||
import { OrderStatus } from '../src/core/order/order-state-machine';
|
||||
|
||||
describe('CompanionOrderService (TDD 测试套件: 线上游戏与同城陪玩·陪吃·陪喝订单及现场核销闭环)', () => {
|
||||
let orderService: CompanionOrderService;
|
||||
|
||||
beforeEach(() => {
|
||||
orderService = new CompanionOrderService();
|
||||
});
|
||||
|
||||
describe('1. 订单创建与差异化字段校验', () => {
|
||||
it('成功创建线上游戏陪玩订单', () => {
|
||||
const params: CreateOrderParams = {
|
||||
buyerId: 1001,
|
||||
expertId: 2001,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者荣耀 · 排位双排包星',
|
||||
hours: 2,
|
||||
totalAmountCents: 7000,
|
||||
gameDetails: {
|
||||
gameName: '王者荣耀',
|
||||
gameId: '峡谷第一野王',
|
||||
serverDistrict: '微信区',
|
||||
},
|
||||
};
|
||||
|
||||
const order = orderService.createOrder(params);
|
||||
expect(order.id).toBeDefined();
|
||||
expect(order.status).toBe(OrderStatus.PENDING_PAYMENT);
|
||||
expect(order.track).toBe(CompanionTrack.ONLINE_GAME);
|
||||
expect(order.gameDetails?.gameId).toBe('峡谷第一野王');
|
||||
});
|
||||
|
||||
it('成功创建同城线下陪吃订单并指定消费AA分摊', () => {
|
||||
const params: CreateOrderParams = {
|
||||
buyerId: 1001,
|
||||
expertId: 2002,
|
||||
track: CompanionTrack.OFFLINE_MEAL,
|
||||
serviceTitle: '同城美食探店 · 网红火锅饭搭子',
|
||||
hours: 3,
|
||||
totalAmountCents: 15000,
|
||||
offlineDetails: {
|
||||
city: '北京市',
|
||||
venueName: '朝阳大悦城海底捞',
|
||||
meetTime: '今晚 19:30',
|
||||
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
||||
},
|
||||
};
|
||||
|
||||
const order = orderService.createOrder(params);
|
||||
expect(order.track).toBe(CompanionTrack.OFFLINE_MEAL);
|
||||
expect(order.offlineDetails?.venueName).toBe('朝阳大悦城海底捞');
|
||||
expect(order.offlineDetails?.expenseBurden).toBe(ExpenseBurdenType.AA_SHARE);
|
||||
});
|
||||
|
||||
it('线下订单缺少商圈场所或城市时应抛出异常', () => {
|
||||
expect(() => {
|
||||
orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2002,
|
||||
track: CompanionTrack.OFFLINE_DRINK,
|
||||
serviceTitle: '星巴克手冲咖啡聊天',
|
||||
hours: 2,
|
||||
totalAmountCents: 10000,
|
||||
offlineDetails: {
|
||||
city: '',
|
||||
venueName: '',
|
||||
meetTime: '下午 15:00',
|
||||
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
||||
},
|
||||
});
|
||||
}).toThrow('线下陪伴订单必须提供有效服务城市与公开碰面场所');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 线下订单 4 位现场核销码流转与履约签到', () => {
|
||||
it('线下订单完成支付并被大神接单后,生成 4 位纯数字现场核销码', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2002,
|
||||
track: CompanionTrack.OFFLINE_MEAL,
|
||||
serviceTitle: '火锅探店',
|
||||
hours: 2,
|
||||
totalAmountCents: 12000,
|
||||
offlineDetails: {
|
||||
city: '北京市',
|
||||
venueName: '大悦城餐厅',
|
||||
meetTime: '19:00',
|
||||
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
||||
},
|
||||
});
|
||||
|
||||
// 买家付款
|
||||
orderService.payOrder(order.id);
|
||||
expect(order.status).toBe(OrderStatus.PENDING_ACCEPT);
|
||||
|
||||
// 大神接单,系统为该订单生成4位现场核销码
|
||||
orderService.expertAccept(order.id, 2002);
|
||||
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM); // 等待现场核销确认进入服务
|
||||
expect(order.offlineVerifyCode).toMatch(/^\d{4}$/);
|
||||
});
|
||||
|
||||
it('大神在现场输入正确的 4 位核销码完成到场签到,进入服务中状态', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2002,
|
||||
track: CompanionTrack.OFFLINE_DRINK,
|
||||
serviceTitle: '下午茶清吧',
|
||||
hours: 2,
|
||||
totalAmountCents: 10000,
|
||||
offlineDetails: {
|
||||
city: '上海市',
|
||||
venueName: '静安嘉里中心咖啡店',
|
||||
meetTime: '15:00',
|
||||
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
||||
},
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id);
|
||||
orderService.expertAccept(order.id, 2002);
|
||||
|
||||
const code = order.offlineVerifyCode!;
|
||||
const checkinResult = orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: 2002,
|
||||
verifyCode: code,
|
||||
});
|
||||
|
||||
expect(checkinResult.success).toBe(true);
|
||||
expect(order.status).toBe(OrderStatus.IN_SERVICE);
|
||||
expect(order.checkedInAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('输入错误的 4 位核销码应报错拒绝', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2002,
|
||||
track: CompanionTrack.OFFLINE_PLAY,
|
||||
serviceTitle: '桌游探案',
|
||||
hours: 2,
|
||||
totalAmountCents: 9000,
|
||||
offlineDetails: {
|
||||
city: '深圳市',
|
||||
venueName: '万象天地桌游吧',
|
||||
meetTime: '14:00',
|
||||
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
||||
},
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id);
|
||||
orderService.expertAccept(order.id, 2002);
|
||||
|
||||
expect(() => {
|
||||
orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: 2002,
|
||||
verifyCode: '0000', // 错误码
|
||||
});
|
||||
}).toThrow('现场核销码错误');
|
||||
});
|
||||
|
||||
it('已核销订单不可重复核销(防重签到)', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2002,
|
||||
track: CompanionTrack.OFFLINE_PLAY,
|
||||
serviceTitle: '桌游探案',
|
||||
hours: 2,
|
||||
totalAmountCents: 9000,
|
||||
offlineDetails: {
|
||||
city: '深圳市',
|
||||
venueName: '万象天地桌游吧',
|
||||
meetTime: '14:00',
|
||||
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
||||
},
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id);
|
||||
orderService.expertAccept(order.id, 2002);
|
||||
|
||||
orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: 2002,
|
||||
verifyCode: order.offlineVerifyCode!,
|
||||
});
|
||||
|
||||
// 再次核销
|
||||
expect(() => {
|
||||
orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: 2002,
|
||||
verifyCode: order.offlineVerifyCode!,
|
||||
});
|
||||
}).toThrow('该订单已完成现场核销签到');
|
||||
});
|
||||
|
||||
it('非接单大神尝试核销应被拒绝', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: 1001,
|
||||
expertId: 2002,
|
||||
track: CompanionTrack.OFFLINE_PLAY,
|
||||
serviceTitle: '密室逃脱',
|
||||
hours: 2,
|
||||
totalAmountCents: 10000,
|
||||
offlineDetails: {
|
||||
city: '成都市',
|
||||
venueName: '春熙路密室馆',
|
||||
meetTime: '16:00',
|
||||
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
||||
},
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id);
|
||||
orderService.expertAccept(order.id, 2002);
|
||||
|
||||
expect(() => {
|
||||
orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: 9999, // 非该订单的大神
|
||||
verifyCode: order.offlineVerifyCode!,
|
||||
});
|
||||
}).toThrow('无权核销该订单');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户