feat(companion): 全面扩展同城生活陪伴体系(陪吃·陪喝·陪玩),完成TDD测试与安全核销闭环

这个提交包含在:
wikiboss
2026-09-16 20:12:54 +08:00
父节点 a90cd3c0d9
当前提交 341023c77b
共有 15 个文件被更改,包括 972 次插入 和 23 次删除

查看文件

@@ -8,6 +8,7 @@ export interface Activity {
creatorId: number;
title: string;
gameCategory: string;
location?: string;
payType: ActivityPayType;
totalFeeCents?: number;
aaFeeCents?: number;
@@ -34,6 +35,7 @@ export class ActivityService {
creatorId: number;
title: string;
gameCategory: string;
location?: string;
payType: ActivityPayType;
totalFeeCents?: number;
aaFeeCents?: number;
@@ -45,6 +47,7 @@ export class ActivityService {
creatorId: params.creatorId,
title: params.title,
gameCategory: params.gameCategory,
location: params.location,
payType: params.payType,
totalFeeCents: params.totalFeeCents,
aaFeeCents: params.aaFeeCents,

查看文件

@@ -0,0 +1,94 @@
/**
* 陪伴服务赛道与品类管理核心
* 支持【线上游戏陪玩】与【同城线下陪玩·陪吃·陪喝】多维服务形态
*/
export enum CompanionTrack {
ONLINE_GAME = 'ONLINE_GAME', // 线上游戏陪玩 (王者/和平/LOL等)
OFFLINE_PLAY = 'OFFLINE_PLAY', // 线下同城陪玩 (桌游/剧本杀/运动/漫展)
OFFLINE_MEAL = 'OFFLINE_MEAL', // 线下同城陪吃 (网红探店/火锅/餐厅饭搭子)
OFFLINE_DRINK = 'OFFLINE_DRINK', // 线下同城陪喝 (咖啡下午茶/茶室清吧)
}
export enum ExpenseBurdenType {
AA_SHARE = 'AA_SHARE', // 现场消费 AA 制各自付
BUYER_TREAT = 'BUYER_TREAT', // 客户全包请客
SELF_CARE = 'SELF_CARE', // 大神自理
}
export interface ServiceItem {
id: string;
track: CompanionTrack;
title: string;
gameName?: string;
city?: string;
venueType?: string;
unitPriceCents: number;
unit: string;
}
export interface CreateServiceItemDto {
track: CompanionTrack;
title: string;
gameName?: string;
city?: string;
venueType?: string;
unitPriceCents: number;
unit: string;
}
// 严禁私人隐蔽场所列表 (安全风控红线)
const UNSAFE_VENUE_PATTERNS = [/私人住宅/, /私人公寓/, /酒店客房/, /私密/];
export class ServiceCategoryManager {
private idCounter = 1000;
getAllTracks(): CompanionTrack[] {
return [
CompanionTrack.ONLINE_GAME,
CompanionTrack.OFFLINE_PLAY,
CompanionTrack.OFFLINE_MEAL,
CompanionTrack.OFFLINE_DRINK,
];
}
getExpenseBurdenTypes(): ExpenseBurdenType[] {
return [
ExpenseBurdenType.AA_SHARE,
ExpenseBurdenType.BUYER_TREAT,
ExpenseBurdenType.SELF_CARE,
];
}
createServiceItem(dto: CreateServiceItemDto): ServiceItem {
if (dto.track === CompanionTrack.ONLINE_GAME) {
if (!dto.gameName || dto.gameName.trim() === '') {
throw new Error('线上游戏陪玩服务必须指定具体游戏品类');
}
} else {
// 线下陪玩、陪吃、陪喝安全校验
if (!dto.city || dto.city.trim() === '') {
throw new Error('线下陪伴服务必须指定服务城市');
}
if (dto.venueType) {
for (const pattern of UNSAFE_VENUE_PATTERNS) {
if (pattern.test(dto.venueType)) {
throw new Error('安全风控触发: 线下陪伴服务仅限在合法的公开商业场所履约,禁止私人住宅或私密空间');
}
}
}
}
return {
id: `srv_${++this.idCounter}`,
track: dto.track,
title: dto.title,
gameName: dto.gameName,
city: dto.city,
venueType: dto.venueType,
unitPriceCents: dto.unitPriceCents,
unit: dto.unit,
};
}
}

查看文件

@@ -0,0 +1,134 @@
import { OrderStatus } from './order-state-machine';
import { CompanionTrack, ExpenseBurdenType } from '../companion/service-category';
export interface GameDetails {
gameName: string;
gameId: string;
serverDistrict?: string;
}
export interface OfflineDetails {
city: string;
venueName: string;
meetTime: string;
expenseBurden: ExpenseBurdenType;
}
export interface CreateOrderParams {
buyerId: number;
expertId: number;
track: CompanionTrack;
serviceTitle: string;
hours: number;
totalAmountCents: number;
gameDetails?: GameDetails;
offlineDetails?: OfflineDetails;
}
export interface CompanionOrder {
id: string;
buyerId: number;
expertId: number;
track: CompanionTrack;
serviceTitle: string;
hours: number;
totalAmountCents: number;
status: OrderStatus;
gameDetails?: GameDetails;
offlineDetails?: OfflineDetails;
offlineVerifyCode?: string;
checkedInAt?: Date;
createdAt: Date;
}
export class CompanionOrderService {
private orders = new Map<string, CompanionOrder>();
private orderCounter = 1000;
createOrder(params: CreateOrderParams): CompanionOrder {
if (params.track !== CompanionTrack.ONLINE_GAME) {
if (
!params.offlineDetails ||
!params.offlineDetails.city?.trim() ||
!params.offlineDetails.venueName?.trim()
) {
throw new Error('线下陪伴订单必须提供有效服务城市与公开碰面场所');
}
}
const orderId = `ord_${Date.now()}_${++this.orderCounter}`;
const order: CompanionOrder = {
id: orderId,
buyerId: params.buyerId,
expertId: params.expertId,
track: params.track,
serviceTitle: params.serviceTitle,
hours: params.hours,
totalAmountCents: params.totalAmountCents,
status: OrderStatus.PENDING_PAYMENT,
gameDetails: params.gameDetails,
offlineDetails: params.offlineDetails,
createdAt: new Date(),
};
this.orders.set(orderId, order);
return order;
}
payOrder(orderId: string): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (order.status !== OrderStatus.PENDING_PAYMENT) {
throw new Error(`订单状态 [${order.status}] 不允许支付`);
}
order.status = OrderStatus.PENDING_ACCEPT;
return order;
}
expertAccept(orderId: string, expertId: number): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (order.expertId !== expertId) throw new Error('非接单大神无权操作');
if (order.status !== OrderStatus.PENDING_ACCEPT) {
throw new Error(`订单当前状态 [${order.status}] 不可接单`);
}
if (order.track === CompanionTrack.ONLINE_GAME) {
order.status = OrderStatus.IN_SERVICE;
} else {
// 线下陪玩/陪吃/陪喝:生成4位纯数字现场到场核销码 (1000-9999)
order.offlineVerifyCode = String(Math.floor(1000 + Math.random() * 9000));
// 线下订单状态置为待现场碰面核销确认
order.status = OrderStatus.PENDING_CONFIRM;
}
return order;
}
verifyOfflineCheckin(params: {
orderId: string;
operatorExpertId: number;
verifyCode: string;
}): { success: boolean; order: CompanionOrder } {
const order = this.orders.get(params.orderId);
if (!order) throw new Error('订单不存在');
if (order.expertId !== params.operatorExpertId) {
throw new Error('无权核销该订单');
}
if (order.checkedInAt) {
throw new Error('该订单已完成现场核销签到');
}
if (!order.offlineVerifyCode || order.offlineVerifyCode !== params.verifyCode) {
throw new Error('现场核销码错误');
}
order.checkedInAt = new Date();
order.status = OrderStatus.IN_SERVICE;
return { success: true, order };
}
}

查看文件

@@ -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);
});
});
});

查看文件

@@ -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('无权核销该订单');
});
});
});