feat(companion): 全面扩展同城生活陪伴体系(陪吃·陪喝·陪玩),完成TDD测试与安全核销闭环
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -223,5 +223,46 @@ export const ICONS = {
|
||||
<line x1="19" y1="5" x2="13.6" y2="10.4"></line>
|
||||
<polyline points="15 5 19 5 19 9"></polyline>
|
||||
</svg>
|
||||
`),
|
||||
|
||||
// 陪吃·美食探店
|
||||
utensils: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 2v6a3 3 0 0 1-3 3 3 3 0 0 1-3-3V2"></path>
|
||||
<path d="M15 2v18"></path>
|
||||
<path d="M5 2v7a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V2"></path>
|
||||
<path d="M8 11v9"></path>
|
||||
</svg>
|
||||
`),
|
||||
|
||||
// 陪喝·咖啡下午茶/茶室清吧
|
||||
coffee: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8h1a4 4 0 0 1 0 8h-1"></path>
|
||||
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"></path>
|
||||
<line x1="6" y1="1" x2="6" y2="4"></line>
|
||||
<line x1="10" y1="1" x2="10" y2="4"></line>
|
||||
<line x1="14" y1="1" x2="14" y2="4"></line>
|
||||
</svg>
|
||||
`),
|
||||
|
||||
// 线下陪玩·同城桌游/剧本杀
|
||||
dice: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#EC4899" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="20" rx="4"></rect>
|
||||
<circle cx="7.5" cy="7.5" r="1.5" fill="#EC4899"></circle>
|
||||
<circle cx="16.5" cy="16.5" r="1.5" fill="#EC4899"></circle>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#EC4899"></circle>
|
||||
<circle cx="16.5" cy="7.5" r="1.5" fill="#EC4899"></circle>
|
||||
<circle cx="7.5" cy="16.5" r="1.5" fill="#EC4899"></circle>
|
||||
</svg>
|
||||
`),
|
||||
|
||||
// 同城位置·商圈
|
||||
mapPin: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#38BDF8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
`)
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 游戏品类选择 -->
|
||||
<!-- 品类选择 -->
|
||||
<view class="form-card">
|
||||
<view class="card-label">选择开黑游戏</view>
|
||||
<view class="card-label">选择活动品类</view>
|
||||
<view class="game-pills">
|
||||
<view
|
||||
v-for="g in games"
|
||||
@@ -49,27 +49,27 @@
|
||||
<input
|
||||
class="row-input"
|
||||
v-model="form.title"
|
||||
placeholder="如:今晚百星王者五排极速冲分"
|
||||
placeholder="如:三里屯火锅探店 / 百星五排极速冲分"
|
||||
placeholder-class="ph"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="input-row">
|
||||
<text class="row-label">开黑时段</text>
|
||||
<text class="row-label">活动时段</text>
|
||||
<input
|
||||
class="row-input"
|
||||
v-model="form.startTime"
|
||||
placeholder="如:今晚 20:30"
|
||||
placeholder="如:今晚 20:30 / 周六 18:30"
|
||||
placeholder-class="ph"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="input-row">
|
||||
<text class="row-label">语音频道</text>
|
||||
<text class="row-label">地点/频道</text>
|
||||
<input
|
||||
class="row-input"
|
||||
v-model="form.location"
|
||||
placeholder="如:开黑语音 1 号房间 / YY 668899"
|
||||
placeholder="如:朝阳区聚宝源 / 开黑语音 1 号房间"
|
||||
placeholder-class="ph"
|
||||
/>
|
||||
</view>
|
||||
@@ -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',
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
<text class="rank-tag">{{ expert.rankTag }}</text>
|
||||
</view>
|
||||
|
||||
<view class="city-row" v-if="expert.city">
|
||||
<image class="city-pin-svg" :src="ICONS.mapPin" mode="aspectFit" />
|
||||
<text class="city-text">{{ expert.city }}</text>
|
||||
</view>
|
||||
|
||||
<view class="data-row">
|
||||
<view class="rating-box">
|
||||
<image class="star-svg" :src="ICONS.star" mode="aspectFit" />
|
||||
@@ -82,6 +87,15 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 线下安全守则提示卡 -->
|
||||
<view class="offline-safety-card" v-if="expert.city">
|
||||
<image class="safety-icon-svg" :src="ICONS.shield" mode="aspectFit" />
|
||||
<view class="safety-content">
|
||||
<text class="safety-title">绿色合规陪伴服务承诺</text>
|
||||
<text class="safety-desc">该大神已完成公安实名联网比对与健康认证。同城陪玩/陪吃/陪喝仅限在公开正规商业场所履约,到场出示4位核销码现场签到。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 战绩与生活照片 -->
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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位码入座,服务中'
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<view class="header-search">
|
||||
<view class="search-box">
|
||||
<image class="search-icon-img" :src="ICONS.search" mode="aspectFit" />
|
||||
<input class="search-input" placeholder="搜索陪玩大神、游戏昵称或项目" placeholder-class="placeholder-text" />
|
||||
<input class="search-input" placeholder="搜索电竞陪玩、同城陪吃陪喝或大神" placeholder-class="placeholder-text" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -110,7 +110,11 @@
|
||||
<image class="gender-svg" :src="expert.gender === 'female' ? ICONS.female : ICONS.male" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="rank-badge">{{ expert.rankTag }}</view>
|
||||
<view class="city-badge" v-if="expert.city">
|
||||
<image class="city-pin-svg" :src="ICONS.mapPin" mode="aspectFit" />
|
||||
<text class="city-text">{{ expert.city }}</text>
|
||||
</view>
|
||||
<view class="rank-badge" v-else>{{ expert.rankTag }}</view>
|
||||
|
||||
<!-- 语音试听条 (矢量SVG播放图标,带播放高亮光晕) -->
|
||||
<view class="voice-bar btn-active" :class="{ playing: playingId === expert.id }" @click.stop="toggleVoice(expert.id)">
|
||||
@@ -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;
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 优惠抵扣与备注 -->
|
||||
<!-- 优惠抵扣与履约约定 -->
|
||||
<view class="order-card">
|
||||
<view class="coupon-row btn-active" @click="selectCoupon">
|
||||
<text class="cell-label">优惠券</text>
|
||||
@@ -70,9 +70,49 @@
|
||||
<text class="arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="remark-row">
|
||||
|
||||
<!-- 线下同城专属:商圈场所与消费约定 -->
|
||||
<block v-if="isOffline">
|
||||
<view class="remark-row">
|
||||
<text class="cell-label">碰面场所</text>
|
||||
<input class="remark-input" v-model="meetVenue" placeholder="必须为公开正规场所,如:朝阳大悦城海底捞" placeholder-class="ph" />
|
||||
</view>
|
||||
<view class="expense-row">
|
||||
<text class="cell-label">现场消费</text>
|
||||
<view class="expense-pills">
|
||||
<view
|
||||
class="expense-pill btn-active"
|
||||
:class="{ active: expenseBurden === 'AA' }"
|
||||
@click="expenseBurden = 'AA'"
|
||||
>
|
||||
AA制各自付
|
||||
</view>
|
||||
<view
|
||||
class="expense-pill btn-active"
|
||||
:class="{ active: expenseBurden === 'TREAT' }"
|
||||
@click="expenseBurden = 'TREAT'"
|
||||
>
|
||||
老板请客
|
||||
</view>
|
||||
<view
|
||||
class="expense-pill btn-active"
|
||||
:class="{ active: expenseBurden === 'SELF' }"
|
||||
@click="expenseBurden = 'SELF'"
|
||||
>
|
||||
大神自理
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="safety-tip-card">
|
||||
<image class="safety-icon-svg" :src="ICONS.shield" mode="aspectFit" />
|
||||
<text class="safety-text">绿色阳光陪伴声明:服务仅限在正规公开商业场所进行。双方碰面后将出示 4 位数字核销码进行现场签到,保障双方人身与财产安全。</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 线上游戏专属:开黑区服与要求 -->
|
||||
<view class="remark-row" v-else>
|
||||
<text class="cell-label">开黑要求/游戏ID</text>
|
||||
<input class="remark-input" v-model="remark" placeholder="如:QQ区/王者段位/包赢" placeholder-class="ph" />
|
||||
<input class="remark-input" v-model="remark" placeholder="如:微信区/王者星耀段位/包赢" placeholder-class="ph" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
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('无权核销该订单');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户