From 341023c77bfefa31ba889b4016b1a69e97f17609 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Wed, 16 Sep 2026 20:12:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(companion):=20=E5=85=A8=E9=9D=A2=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E5=90=8C=E5=9F=8E=E7=94=9F=E6=B4=BB=E9=99=AA=E4=BC=B4?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=EF=BC=88=E9=99=AA=E5=90=83=C2=B7=E9=99=AA?= =?UTF-8?q?=E5=96=9D=C2=B7=E9=99=AA=E7=8E=A9=EF=BC=89=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E6=88=90TDD=E6=B5=8B=E8=AF=95=E4=B8=8E=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=A0=B8=E9=94=80=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/陪玩小程序开发设计文档.md | 15 +- pwuniapp/common/icons.js | 41 ++++ pwuniapp/common/mock.js | 91 ++++++- pwuniapp/pages.json | 4 +- pwuniapp/pages/activity/create.vue | 16 +- pwuniapp/pages/expert/detail.vue | 66 +++++ pwuniapp/pages/expert/workspace.vue | 16 ++ pwuniapp/pages/index/index.vue | 29 ++- pwuniapp/pages/order/create.vue | 116 ++++++++- server/src/core/activity/activity.service.ts | 3 + server/src/core/companion/service-category.ts | 94 +++++++ .../src/core/order/companion-order.service.ts | 134 ++++++++++ server/tests/activity.spec.ts | 29 +++ server/tests/companion-category.spec.ts | 112 +++++++++ server/tests/companion-order.spec.ts | 229 ++++++++++++++++++ 15 files changed, 972 insertions(+), 23 deletions(-) create mode 100644 server/src/core/companion/service-category.ts create mode 100644 server/src/core/order/companion-order.service.ts create mode 100644 server/tests/companion-category.spec.ts create mode 100644 server/tests/companion-order.spec.ts 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 @@ > - + + + + + 碰面场所 + + + + 现场消费 + + + AA制各自付 + + + 老板请客 + + + 大神自理 + + + + + + 绿色阳光陪伴声明:服务仅限在正规公开商业场所进行。双方碰面后将出示 4 位数字核销码进行现场签到,保障双方人身与财产安全。 + + + + + 开黑要求/游戏ID - + @@ -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('无权核销该订单'); + }); + }); +});