diff --git a/docs/陪玩小程序开发设计文档.md b/docs/陪玩小程序开发设计文档.md
index 6b5c46c..02fe498 100644
--- a/docs/陪玩小程序开发设计文档.md
+++ b/docs/陪玩小程序开发设计文档.md
@@ -1,11 +1,11 @@
# 陪玩小程序系统开发设计文档
## 文档基本信息
-- **项目名称**:游戏陪玩小程序(多端互动与活动撮合平台)
+- **项目名称**:陪玩小程序(线上电竞与同城生活陪伴综合社交撮合平台)
- **适用端**:用户端(玩家微信小程序)、服务端(大神微信小程序,支持双身份即时无缝切换)、PC总后台管理端(Web控制台)
-- **版本号**:v1.0.0
+- **版本号**:v1.1.0
- **编制日期**:2026-09-16
-- **设计目标**:构建集“陪玩即时撮合、预约下单、活动拼单(AA制/项目制+4位核销码)、社区动态、分销裂变、资金清算与多端运营管控”于一体的电竞陪玩社交闭环系统。
+- **设计目标**:构建集“线上电竞陪玩、同城生活陪伴(陪吃·陪喝·陪玩)、预约下单、活动拼单(AA制/项目制+4位核销码)、消费分摊约定、社区动态、分销裂变、资金清算与多端运营风控”于一体的综合陪伴社交闭环系统。
---
@@ -23,8 +23,13 @@
## 1. 项目概述与业务架构
-### 1.1 项目定位
-本项目致力于打造高效、合规、有趣的泛娱乐游戏陪玩与社交活动平台。系统打破传统单向买卖壁垒,实现**玩家与陪玩双身份一体化切换**,同时创新性引入**活动项目(AA制均摊与项目制发起)与线下/线上4位数字核销码签到**机制,结合二级分销海报裂变和多渠道提现审核,形成稳定的商业变现闭环。
+### 1.1 项目定位与业务范畴
+本项目致力于打造高效、合规、有趣的泛娱乐线上电竞与同城生活社交陪伴平台。系统明确:**“陪玩”不仅仅包含线上游戏陪玩,更涵盖丰富的同城线下生活陪伴(陪玩·陪吃·陪喝)**:
+- **线上电竞陪玩**:王者荣耀、和平精英、英雄联盟、无畏契约等上分、娱乐与连麦教学;
+- **同城美食陪吃**:火锅烤肉、网红探店、商场餐饮搭子,前置约定现场消费分摊机制(AA制各自付 / 老板请客 / 大神自理);
+- **同城下午茶陪喝**:精品咖啡手冲品鉴、新中式茶室、商业清吧微醺倾听解压;
+- **同城线下陪玩**:剧本杀探案、密室逃脱、桌游互动、漫展同游、户外羽毛球运动等;
+- **履约安全风控**:线下服务强制限定在公开正规商业场所,严禁私密幽闭空间,双方碰面后出示 **4 位数字核销码**现场到场签到,确保真实履约与人身财产安全。系统打破传统单向买卖壁垒,实现**用户与陪伴大神双身份一体化平滑切换**。
### 1.2 角色权限与三端架构
```mermaid
diff --git a/pwuniapp/common/icons.js b/pwuniapp/common/icons.js
index fbd06d0..6118509 100644
--- a/pwuniapp/common/icons.js
+++ b/pwuniapp/common/icons.js
@@ -223,5 +223,46 @@ export const ICONS = {
+ `),
+
+ // 陪吃·美食探店
+ utensils: svgToUri(`
+
+ `),
+
+ // 陪喝·咖啡下午茶/茶室清吧
+ coffee: svgToUri(`
+
+ `),
+
+ // 线下陪玩·同城桌游/剧本杀
+ dice: svgToUri(`
+
+ `),
+
+ // 同城位置·商圈
+ mapPin: svgToUri(`
+
`)
};
diff --git a/pwuniapp/common/mock.js b/pwuniapp/common/mock.js
index b0b0087..03e73b7 100644
--- a/pwuniapp/common/mock.js
+++ b/pwuniapp/common/mock.js
@@ -2,12 +2,13 @@ import { ICONS } from './icons.js';
export const GAMES_LIST = [
{ id: 'all', name: '全部', icon: ICONS.gameAll, hot: false },
- { id: 'wzry', name: '王者荣耀', icon: ICONS.gameCrown, hot: true },
+ { id: 'offline_meal', name: '美食陪吃', icon: ICONS.utensils, hot: true },
+ { id: 'offline_drink', name: '下午茶陪喝', icon: ICONS.coffee, hot: false },
+ { id: 'offline_play', name: '同城陪玩', icon: ICONS.dice, hot: true },
+ { id: 'wzry', name: '王者荣耀', icon: ICONS.gameCrown, hot: false },
{ id: 'hpjy', name: '和平精英', icon: ICONS.gameCrosshair, hot: false },
{ id: 'lol', name: '英雄联盟', icon: ICONS.gameSwords, hot: false },
{ id: 'val', name: '无畏契约', icon: ICONS.gameTarget, hot: false },
- { id: 'yjjw', name: '永劫无间', icon: ICONS.gameBlade, hot: false },
- { id: 'pubg', name: '绝地求生', icon: ICONS.gameTrophy, hot: false },
];
export const BANNERS = [
@@ -118,6 +119,58 @@ export const EXPERTS_LIST = [
],
reviews: [],
},
+ {
+ id: 'exp_105',
+ nickname: '橙子小七',
+ avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300&h=300&fit=crop&crop=faces',
+ coverImage: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&h=400&fit=crop',
+ gender: 'female',
+ age: 22,
+ game: '美食陪吃',
+ track: 'OFFLINE_MEAL',
+ city: '北京市 · 朝阳/三里屯',
+ rankTag: '美食探店达人 · 火锅烤肉搭子',
+ levelTag: '同城红人',
+ price: 68,
+ rating: 5.0,
+ orderCount: 420,
+ isOnline: true,
+ voiceDuration: 14,
+ bio: '资深美食探店博主,性格随和开朗,陪你吃遍同城网红餐厅与地道市井美味,消费约定AA或请客~',
+ photos: ['https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=500&fit=crop'],
+ services: [
+ { id: 'srv_7', title: '同城美食探店 · 火锅/烤肉饭搭子', price: 68, unit: '小时', desc: '公开商场餐厅,绝不踩雷,现场AA或买单' },
+ { id: 'srv_8', title: '商圈早午餐/轻食下午茶陪伴', price: 58, unit: '小时', desc: '轻松闲聊,氛围愉快,阳光公开场所' },
+ ],
+ reviews: [
+ { id: 'r4', userNickname: '吃货小张', userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100', rating: 5, tags: ['随和健谈', '推荐靠谱'], content: '火锅店推荐的太好吃了,聊得很投缘!', time: '昨天' },
+ ],
+ },
+ {
+ id: 'exp_106',
+ nickname: '莫吉托学长',
+ avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=300&h=300&fit=crop&crop=faces',
+ coverImage: 'https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=800&h=400&fit=crop',
+ gender: 'male',
+ age: 24,
+ game: '下午茶陪喝',
+ track: 'OFFLINE_DRINK',
+ city: '北京市 · 国贸/望京',
+ rankTag: '手冲咖啡师 · 清吧倾听解压',
+ levelTag: '暖心导师',
+ price: 58,
+ rating: 4.9,
+ orderCount: 310,
+ isOnline: true,
+ voiceDuration: 16,
+ bio: '前五星酒店调酒师与SCA认证咖啡师,善于倾听情绪解压,阳光透明,约定公开正规咖啡馆或清吧。',
+ photos: ['https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=500&fit=crop'],
+ services: [
+ { id: 'srv_9', title: '精品咖啡品鉴 · 聊天解压倾听', price: 58, unit: '小时', desc: '星巴克臻选/独立咖啡馆,温和交流' },
+ { id: 'srv_10', title: '正规清吧微醺特调陪伴', price: 78, unit: '小时', desc: '限公开正规商业清吧,严禁过量饮酒' },
+ ],
+ reviews: [],
+ },
];
export const ACTIVITIES_LIST = [
@@ -176,6 +229,25 @@ export const ACTIVITIES_LIST = [
status: 'RECRUITING',
participants: [],
},
+ {
+ id: 'act_104',
+ title: '【AA制火锅】周末三里屯地道铜锅涮肉探店搭子',
+ game: '美食陪吃',
+ track: 'OFFLINE_MEAL',
+ payType: 'AA',
+ totalFee: 200,
+ aaFee: 50,
+ maxParticipants: 4,
+ currentParticipants: 2,
+ startTime: '周六 18:30',
+ location: '朝阳区三里屯商圈聚宝源',
+ creatorNickname: '橙子小七',
+ creatorAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100',
+ status: 'RECRUITING',
+ participants: [
+ { id: 'p6', nickname: '涮肉小行家', avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100', verifyCode: '5821', checkedIn: false },
+ ],
+ },
];
export const ORDERS_LIST = [
@@ -189,6 +261,19 @@ export const ORDERS_LIST = [
status: 'IN_SERVICE',
createdAt: '2026-09-16 15:30',
},
+ {
+ id: 'ord_4',
+ orderSn: 'PW20260916099',
+ expert: { id: 'exp_105', nickname: '橙子小七', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100', game: '美食陪吃' },
+ serviceTitle: '同城美食探店 · 火锅/烤肉饭搭子',
+ quantity: 2,
+ totalAmount: 136,
+ status: 'IN_SERVICE',
+ meetVenue: '朝阳大悦城海底捞',
+ expenseBurden: 'AA制各自付',
+ verifyCode: '6829',
+ createdAt: '2026-09-16 18:00',
+ },
{
id: 'ord_2',
orderSn: 'PW20260915088',
diff --git a/pwuniapp/pages.json b/pwuniapp/pages.json
index f136db0..b40f538 100644
--- a/pwuniapp/pages.json
+++ b/pwuniapp/pages.json
@@ -3,7 +3,7 @@
{
"path": "pages/index/index",
"style": {
- "navigationBarTitleText": "电竞陪玩大厅",
+ "navigationBarTitleText": "陪玩中心",
"navigationBarBackgroundColor": "#0A0F1D",
"navigationBarTextStyle": "white",
"backgroundColor": "#0A0F1D"
@@ -93,7 +93,7 @@
],
"globalStyle": {
"navigationBarTextStyle": "white",
- "navigationBarTitleText": "电竞陪玩",
+ "navigationBarTitleText": "陪玩中心",
"navigationBarBackgroundColor": "#0A0F1D",
"backgroundColor": "#0A0F1D"
},
diff --git a/pwuniapp/pages/activity/create.vue b/pwuniapp/pages/activity/create.vue
index 7c2ab76..869f112 100644
--- a/pwuniapp/pages/activity/create.vue
+++ b/pwuniapp/pages/activity/create.vue
@@ -26,9 +26,9 @@
-
+
- 选择开黑游戏
+ 选择活动品类
- 开黑时段
+ 活动时段
- 语音频道
+ 地点/频道
@@ -133,7 +133,7 @@ import { ref, computed } from 'vue';
import { ICONS } from '@/common/icons.js';
import { ACTIVITIES_LIST } from '@/common/mock.js';
-const games = ['王者荣耀', '和平精英', '英雄联盟', '无畏契约'];
+const games = ['王者荣耀', '和平精英', '美食陪吃', '同城陪玩', '下午茶陪喝', '英雄联盟'];
const form = ref({
payType: 'AA',
diff --git a/pwuniapp/pages/expert/detail.vue b/pwuniapp/pages/expert/detail.vue
index c18ded2..70e527b 100644
--- a/pwuniapp/pages/expert/detail.vue
+++ b/pwuniapp/pages/expert/detail.vue
@@ -25,6 +25,11 @@
{{ expert.rankTag }}
+
+
+ {{ expert.city }}
+
+
@@ -82,6 +87,15 @@
+
+
+
+
+
+ 绿色合规陪伴服务承诺
+ 该大神已完成公安实名联网比对与健康认证。同城陪玩/陪吃/陪喝仅限在公开正规商业场所履约,到场出示4位核销码现场签到。
+
+
@@ -322,6 +336,23 @@ const goToOrder = () => {
}
}
+ .city-row {
+ display: flex;
+ align-items: center;
+ gap: 6rpx;
+ margin-bottom: 8rpx;
+
+ .city-pin-svg {
+ width: 22rpx;
+ height: 22rpx;
+ }
+ .city-text {
+ font-size: 20rpx;
+ color: #38BDF8;
+ font-weight: 500;
+ }
+ }
+
.data-row {
display: flex;
align-items: center;
@@ -494,6 +525,41 @@ const goToOrder = () => {
}
}
+ .offline-safety-card {
+ margin-top: 20rpx;
+ background: rgba(16, 185, 129, 0.08);
+ border: 1px solid rgba(16, 185, 129, 0.2);
+ border-radius: 16rpx;
+ padding: 16rpx 20rpx;
+ display: flex;
+ align-items: flex-start;
+ gap: 12rpx;
+
+ .safety-icon-svg {
+ width: 32rpx;
+ height: 32rpx;
+ flex-shrink: 0;
+ margin-top: 4rpx;
+ }
+ .safety-content {
+ flex: 1;
+
+ .safety-title {
+ font-size: 22rpx;
+ font-weight: 600;
+ color: #10B981;
+ display: block;
+ margin-bottom: 4rpx;
+ }
+ .safety-desc {
+ font-size: 20rpx;
+ color: #94A3B8;
+ line-height: 1.4;
+ display: block;
+ }
+ }
+ }
+
.gallery-scroll {
white-space: nowrap;
diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue
index 655f3ef..27ec78c 100644
--- a/pwuniapp/pages/expert/workspace.vue
+++ b/pwuniapp/pages/expert/workspace.vue
@@ -235,6 +235,13 @@ const poolOrders = ref([
price: 45,
desc: '微信区/四排海岛/刚枪上分/kd保4以上',
countdown: 32
+ },
+ {
+ id: 'pool_103',
+ game: '美食陪吃',
+ price: 68,
+ desc: '朝阳大悦城海底捞/周末饭搭子/消费现场AA/需守时礼貌',
+ countdown: 56
}
]);
@@ -247,6 +254,15 @@ const activeOrders = ref([
hours: 2,
totalAmount: 70,
statusText: '正在开黑连麦对局中'
+ },
+ {
+ id: 'ord_active_2',
+ buyerName: '北京吃货小张',
+ buyerAvatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop',
+ serviceTitle: '火锅探店饭搭子',
+ hours: 2,
+ totalAmount: 136,
+ statusText: '已核销4位码入座,服务中'
}
]);
diff --git a/pwuniapp/pages/index/index.vue b/pwuniapp/pages/index/index.vue
index a8dd074..82a0655 100644
--- a/pwuniapp/pages/index/index.vue
+++ b/pwuniapp/pages/index/index.vue
@@ -4,7 +4,7 @@
@@ -110,7 +110,11 @@
- {{ expert.rankTag }}
+
+
+ {{ expert.city }}
+
+ {{ expert.rankTag }}
@@ -151,6 +155,9 @@ const playingId = ref(null);
const filteredExperts = computed(() => {
if (currentCategory.value === 'all') return experts.value;
const gameMap = {
+ offline_meal: '美食陪吃',
+ offline_drink: '下午茶陪喝',
+ offline_play: '同城陪玩',
wzry: '王者荣耀',
hpjy: '和平精英',
lol: '英雄联盟',
@@ -563,6 +570,24 @@ const navTo = (url) => {
}
}
+ .city-badge {
+ font-size: 20rpx;
+ color: #38BDF8;
+ background: rgba(56, 189, 248, 0.12);
+ padding: 4rpx 10rpx;
+ border-radius: 6rpx;
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 12rpx;
+ border: 1px solid rgba(56, 189, 248, 0.2);
+
+ .city-pin-svg {
+ width: 20rpx;
+ height: 20rpx;
+ margin-right: 6rpx;
+ }
+ }
+
.rank-badge {
font-size: 20rpx;
color: #F59E0B;
diff --git a/pwuniapp/pages/order/create.vue b/pwuniapp/pages/order/create.vue
index a9eb43e..d1eebbe 100644
--- a/pwuniapp/pages/order/create.vue
+++ b/pwuniapp/pages/order/create.vue
@@ -61,7 +61,7 @@
-
+
优惠券
@@ -70,9 +70,49 @@
>
-
@@ -115,6 +155,19 @@ const hours = ref(2);
const timeType = ref('NOW');
const discount = ref(10);
const remark = ref('');
+const meetVenue = ref('朝阳大悦城海底捞');
+const expenseBurden = ref('AA');
+
+const isOffline = computed(() => {
+ return expert.value && (
+ expert.value.track === 'OFFLINE_MEAL' ||
+ expert.value.track === 'OFFLINE_DRINK' ||
+ expert.value.track === 'OFFLINE_PLAY' ||
+ expert.value.game === '美食陪吃' ||
+ expert.value.game === '下午茶陪喝' ||
+ expert.value.game === '同城陪玩'
+ );
+});
onLoad((query) => {
if (query && query.expertId) {
@@ -389,6 +442,63 @@ const handlePay = () => {
}
}
+ .expense-row {
+ display: flex;
+ align-items: center;
+ padding: 20rpx 0;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+
+ .cell-label {
+ font-size: 24rpx;
+ color: #64748B;
+ width: 220rpx;
+ }
+
+ .expense-pills {
+ display: flex;
+ gap: 12rpx;
+
+ .expense-pill {
+ font-size: 22rpx;
+ color: #94A3B8;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 6rpx 16rpx;
+ border-radius: 12rpx;
+
+ &.active {
+ color: #F59E0B;
+ background: rgba(245, 158, 11, 0.15);
+ border-color: #F59E0B;
+ font-weight: 600;
+ }
+ }
+ }
+ }
+
+ .safety-tip-card {
+ margin-top: 20rpx;
+ background: rgba(16, 185, 129, 0.08);
+ border: 1px solid rgba(16, 185, 129, 0.2);
+ border-radius: 16rpx;
+ padding: 16rpx 20rpx;
+ display: flex;
+ align-items: flex-start;
+ gap: 12rpx;
+
+ .safety-icon-svg {
+ width: 28rpx;
+ height: 28rpx;
+ flex-shrink: 0;
+ margin-top: 4rpx;
+ }
+ .safety-text {
+ font-size: 20rpx;
+ color: #10B981;
+ line-height: 1.5;
+ }
+ }
+
.fee-row {
display: flex;
justify-content: space-between;
diff --git a/server/src/core/activity/activity.service.ts b/server/src/core/activity/activity.service.ts
index 76c7589..4f68f55 100644
--- a/server/src/core/activity/activity.service.ts
+++ b/server/src/core/activity/activity.service.ts
@@ -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,
diff --git a/server/src/core/companion/service-category.ts b/server/src/core/companion/service-category.ts
new file mode 100644
index 0000000..01b1bda
--- /dev/null
+++ b/server/src/core/companion/service-category.ts
@@ -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,
+ };
+ }
+}
diff --git a/server/src/core/order/companion-order.service.ts b/server/src/core/order/companion-order.service.ts
new file mode 100644
index 0000000..73e2c2a
--- /dev/null
+++ b/server/src/core/order/companion-order.service.ts
@@ -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();
+ 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 };
+ }
+}
diff --git a/server/tests/activity.spec.ts b/server/tests/activity.spec.ts
index e2dae2b..dbd521e 100644
--- a/server/tests/activity.spec.ts
+++ b/server/tests/activity.spec.ts
@@ -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);
+ });
+ });
});
+
diff --git a/server/tests/companion-category.spec.ts b/server/tests/companion-category.spec.ts
new file mode 100644
index 0000000..e8f4cdf
--- /dev/null
+++ b/server/tests/companion-category.spec.ts
@@ -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);
+ });
+ });
+});
diff --git a/server/tests/companion-order.spec.ts b/server/tests/companion-order.spec.ts
new file mode 100644
index 0000000..e0152ee
--- /dev/null
+++ b/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('无权核销该订单');
+ });
+ });
+});