feat(companion): 全面扩展同城生活陪伴体系(陪吃·陪喝·陪玩),完成TDD测试与安全核销闭环
这个提交包含在:
@@ -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 };
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户