feat(order): 平台派单闭环(发需求单进入待接单池)
- 下单页新增「指定大神 / 平台派单」模式:平台派单填品类、预算、地点/选点,expertId=0 - 服务端:平台派单单据支付后进入待接单池(余额支付即时入池,微信支付确认后入池),预算由买家出价但受 1~2000 元/小时约束 - 抢单由认领大神写入 expert_id 并转履约中;30 分钟无人接单沿用自动全额退款 - 修复订单 INSERT 占位符少一个导致下单报 Column count doesn't match 的问题,并新增列数/占位符一致性护栏 - 111 套件/737 用例全绿;线上 1 元实测:下单→待接单→池内 640m,测试数据已回滚
这个提交包含在:
@@ -1,7 +1,21 @@
|
||||
<template>
|
||||
<view class="create-order-container" :class="{ 'theme-warm': currentThemeKey === 'warm' }">
|
||||
<!-- 下单模式切换:指定大神 / 平台派单 -->
|
||||
<view class="order-card mode-card">
|
||||
<view class="mode-tabs">
|
||||
<view class="mode-tab btn-active" :class="{ active: orderMode === 'EXPERT' }" @click="switchMode('EXPERT')">
|
||||
<text class="mode-title">指定大神</text>
|
||||
<text class="mode-desc">按大神定价直接下单</text>
|
||||
</view>
|
||||
<view class="mode-tab btn-active" :class="{ active: orderMode === 'PLATFORM' }" @click="switchMode('PLATFORM')">
|
||||
<text class="mode-title">平台派单</text>
|
||||
<text class="mode-desc">发布需求 · 附近大神抢单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 服务大神与项目信息 -->
|
||||
<view class="order-card">
|
||||
<view class="order-card" v-if="!isPlatformMode">
|
||||
<view class="expert-profile">
|
||||
<image class="expert-avatar" :src="expert.avatar" mode="aspectFill" />
|
||||
<view class="expert-meta">
|
||||
@@ -17,6 +31,30 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 平台派单:品类与预算 -->
|
||||
<view class="order-card" v-if="isPlatformMode">
|
||||
<view class="section-title">需求品类</view>
|
||||
<view class="platform-cats">
|
||||
<view
|
||||
v-for="cat in platformCategories"
|
||||
:key="cat"
|
||||
class="platform-cat btn-active"
|
||||
:class="{ active: platformCategory === cat }"
|
||||
@click="selectPlatformCategory(cat)"
|
||||
>
|
||||
{{ cat }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="remark-row">
|
||||
<text class="cell-label">预算 (元/小时)</text>
|
||||
<input class="remark-input" type="digit" v-model="platformBudget" placeholder="如 88" placeholder-class="ph" />
|
||||
</view>
|
||||
<view class="safety-tip-card">
|
||||
<image class="safety-icon-svg" :src="ICONS.shield" mode="aspectFit" />
|
||||
<text class="safety-text">支付后进入附近大神派单池,5km 内认证大神可抢单;30 分钟内无人接单将自动全额退款。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数量/时长步进器 -->
|
||||
<view class="order-card">
|
||||
<view class="stepper-row">
|
||||
@@ -240,7 +278,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { createOrder, payOrder as payOrderApi, requestWechatPayment, getCompanionDetail, getUserProfile, getMyCoupons } from '@/common/api.js';
|
||||
import { createOrder, payOrder as payOrderApi, requestWechatPayment, getCompanionDetail, getUserProfile, getMyCoupons, normalizeUserId } from '@/common/api.js';
|
||||
import { preloadSubscribeTemplates, requestSubscribeFor } from '@/common/subscribe.js';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
@@ -329,6 +367,27 @@ const remark = ref('');
|
||||
const meetVenue = ref('朝阳大悦城海底捞');
|
||||
const meetCoords = ref({ latitude: null, longitude: null });
|
||||
|
||||
// 下单模式:EXPERT=指定大神,PLATFORM=平台派单(发布需求,附近大神抢单)
|
||||
const orderMode = ref('EXPERT');
|
||||
const platformCategory = ref('同城美食陪吃');
|
||||
const platformBudget = ref(88);
|
||||
const platformCategories = ['同城美食陪吃', '下午茶陪喝', '同城KTV陪唱', '同城陪玩', '线上电竞开黑'];
|
||||
const isPlatformMode = computed(() => orderMode.value === 'PLATFORM');
|
||||
|
||||
const selectPlatformCategory = (cat) => {
|
||||
platformCategory.value = cat;
|
||||
};
|
||||
|
||||
const switchMode = (mode) => {
|
||||
if (mode === orderMode.value) return;
|
||||
orderMode.value = mode;
|
||||
if (mode === 'PLATFORM') {
|
||||
// 平台派单必须由买家重新选点,避免沿用指定大神页的默认地点造成错误距离
|
||||
meetVenue.value = '';
|
||||
meetCoords.value = { latitude: null, longitude: null };
|
||||
}
|
||||
};
|
||||
|
||||
const pickVenue = () => {
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
uni.chooseLocation({
|
||||
@@ -352,6 +411,7 @@ const pickVenue = () => {
|
||||
const expenseBurden = ref('AA');
|
||||
|
||||
const isOffline = computed(() => {
|
||||
if (isPlatformMode.value) return platformCategory.value !== '线上电竞开黑';
|
||||
return expert.value && (
|
||||
expert.value.track === 'OFFLINE_MEAL' ||
|
||||
expert.value.track === 'OFFLINE_DRINK' ||
|
||||
@@ -412,6 +472,9 @@ onLoad(async (query) => {
|
||||
});
|
||||
|
||||
const rawTotal = computed(() => {
|
||||
if (isPlatformMode.value) {
|
||||
return (Number(platformBudget.value) || 0) * hours.value;
|
||||
}
|
||||
return (selectedService.value ? selectedService.value.price : 35) * hours.value;
|
||||
});
|
||||
|
||||
@@ -516,6 +579,17 @@ const handlePay = async () => {
|
||||
if (!ensureLoginOrRedirect('/pages/order/create')) {
|
||||
return;
|
||||
}
|
||||
// 平台派单校验:品类预算与碰面地点
|
||||
if (isPlatformMode.value) {
|
||||
if (!(Number(platformBudget.value) > 0)) {
|
||||
uni.showToast({ title: '请填写有效的预算(元/小时)', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (isOffline.value && !String(meetVenue.value || '').trim()) {
|
||||
uni.showToast({ title: '请填写或选择碰面地点', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 下单/支付前请求一次性订阅授权(订单状态 + 核销码通知)
|
||||
requestSubscribeFor('order');
|
||||
if (payMethod.value === 'BALANCE' && Number(userBalance.value) < finalTotal.value) {
|
||||
@@ -523,18 +597,23 @@ const handlePay = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = Number(uni.getStorageSync('userId') || currentUserId.value || 1001);
|
||||
const serviceTitle = selectedService.value ? selectedService.value.title : `${expert.value.game}专属服务`;
|
||||
const uid = normalizeUserId();
|
||||
const isPlatform = isPlatformMode.value;
|
||||
const serviceTitle = isPlatform
|
||||
? `${platformCategory.value} · 平台派单需求`
|
||||
: (selectedService.value ? selectedService.value.title : `${expert.value.game}专属服务`);
|
||||
const usingUserCoupon = currentCoupon.value && currentCoupon.value.id && currentCoupon.value.id !== 'c_none';
|
||||
const basePayload = {
|
||||
buyerId: uid,
|
||||
clientName: currentUserName.value || '超能电竞玩家',
|
||||
expertId: expert.value.id,
|
||||
companionName: expert.value.nickname,
|
||||
expertAvatar: expert.value.avatar,
|
||||
track: expert.value.game || (isOffline.value ? '同城生活' : '线上电竞'),
|
||||
expertId: isPlatform ? 0 : expert.value.id,
|
||||
companionName: isPlatform ? '平台派单' : expert.value.nickname,
|
||||
expertAvatar: isPlatform ? '' : expert.value.avatar,
|
||||
track: isPlatform ? platformCategory.value : (expert.value.game || (isOffline.value ? '同城生活' : '线上电竞')),
|
||||
serviceTitle,
|
||||
hourlyRate: selectedService.value ? selectedService.value.price : (expert.value.price || 35),
|
||||
hourlyRate: isPlatform
|
||||
? (Number(platformBudget.value) || 88)
|
||||
: (selectedService.value ? selectedService.value.price : (expert.value.price || 35)),
|
||||
durationHours: hours.value,
|
||||
amount: finalTotal.value,
|
||||
couponDiscount: usingUserCoupon ? 0 : discount.value,
|
||||
@@ -647,6 +726,66 @@ const handlePay = async () => {
|
||||
margin-bottom: 24rpx;
|
||||
border: 1px solid var(--border-subtle);
|
||||
|
||||
&.mode-card {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
padding: 18rpx 12rpx;
|
||||
border-radius: 18rpx;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface-elevated);
|
||||
|
||||
&.active {
|
||||
border-color: #10B981;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.mode-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.platform-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin: 16rpx 0 8rpx;
|
||||
|
||||
.platform-cat {
|
||||
padding: 12rpx 22rpx;
|
||||
border-radius: 28rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
|
||||
&.active {
|
||||
color: #FFFFFF;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expert-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1458,15 +1458,21 @@ export class MysqlRepository {
|
||||
throw err;
|
||||
}
|
||||
const clientName = data.clientName || '超能电竞玩家';
|
||||
const expertId = Number(data.expertId) || 101;
|
||||
const companionName = data.companionName || '同城大神';
|
||||
// 平台派单:不指定大神(expertId=0),进入待接单池由附近大神抢单
|
||||
const isMarketplace = !Number(data.expertId) || Number(data.expertId) <= 0;
|
||||
const expertId = isMarketplace ? 0 : Number(data.expertId);
|
||||
const companionName = data.companionName || (isMarketplace ? '平台派单' : '同城大神');
|
||||
const expertAvatar = data.expertAvatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120';
|
||||
const track = data.track || '同城生活';
|
||||
const serviceTitle = data.serviceTitle || `${track} · 专业陪伴服务`;
|
||||
const durationHours = Math.min(24, Math.max(1, Math.round(Number(data.durationHours) || 2)));
|
||||
|
||||
// 服务单价以服务端大神档案为准(本人基础价,或该大神名下服务包价格),杜绝客户端自定义低价
|
||||
// 服务单价以服务端大神档案为准(本人基础价,或该大神名下服务包价格),杜绝客户端自定义低价。
|
||||
// 平台派单没有大神档案,由买家出预算(服务端做上下限约束)。
|
||||
let hourlyRate = Number(data.hourlyRate) || 35;
|
||||
if (isMarketplace) {
|
||||
hourlyRate = Math.min(2000, Math.max(1, Number(data.hourlyRate) || 35));
|
||||
} else {
|
||||
try {
|
||||
const comp = await this.resolveCompanionRow(expertId);
|
||||
if (comp) {
|
||||
@@ -1483,6 +1489,7 @@ export class MysqlRepository {
|
||||
} catch (e) {
|
||||
logServerError('createOrder.validateRate', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 优惠券:优先使用"我的券"(userCouponId,校验归属/门槛/类目/有效期),否则退回面额匹配平台有效券
|
||||
const baseAmount = Number((hourlyRate * durationHours).toFixed(2));
|
||||
@@ -1513,7 +1520,10 @@ export class MysqlRepository {
|
||||
const verifyCode = String(Math.floor(1000 + Math.random() * 9000));
|
||||
const remark = data.remark || '';
|
||||
// 初始订单状态由服务端按支付方式决定,杜绝客户端创建"已支付/已履约/已完成"订单绕过付款
|
||||
const status = payType === 'BALANCE' ? '履约中' : '待付款';
|
||||
// 平台派单:余额支付后直接进入待接单池;微信支付则先待付款,支付确认后转入待接单
|
||||
const status = payType === 'BALANCE'
|
||||
? (isMarketplace ? '待接单' : '履约中')
|
||||
: '待付款';
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
@@ -1548,7 +1558,7 @@ export class MysqlRepository {
|
||||
expense_burden, hourly_rate, duration_hours, amount,
|
||||
coupon_discount, user_coupon_id, pay_type,
|
||||
location, latitude, longitude, verify_code, remark, status, paid_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
|
||||
`, [
|
||||
id, buyerId, expertId, orderNo, clientName,
|
||||
companionName, expertAvatar, track, serviceTitle,
|
||||
@@ -1696,6 +1706,7 @@ export class MysqlRepository {
|
||||
id: r.id,
|
||||
orderNo: r.orderNo,
|
||||
buyerId: Number(r.buyerId) || 0,
|
||||
expertId: Number(r.expertId) || 0,
|
||||
amount: Number(r.amount) || 0,
|
||||
companionName: r.companionName || '',
|
||||
status: r.status,
|
||||
|
||||
@@ -2222,8 +2222,20 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
|
||||
if (!paid) {
|
||||
return res.status(400).json({ code: 40000, message: '微信支付尚未完成,无法确认订单支付' });
|
||||
}
|
||||
const order = await repo.updateOrderStatus(orderId, '履约中');
|
||||
const isMarketplace = !Number(own.expertId);
|
||||
const paidStatus = isMarketplace ? '待接单' : '履约中';
|
||||
const order = await repo.updateOrderStatus(orderId, paidStatus);
|
||||
const buyer = Number(own.buyerId || 0);
|
||||
if (isMarketplace) {
|
||||
pushUserNotification(
|
||||
buyer,
|
||||
'ORDER_STATUS',
|
||||
'支付成功,等待大神接单',
|
||||
`订单 ${order?.order_no || orderId} 已支付,正在为您匹配附近大神;30 分钟内无人接单将自动全额退款。`,
|
||||
'/pages/order/list'
|
||||
);
|
||||
return res.json({ code: 20000, data: order, message: '支付成功,已进入派单池,等待附近大神接单' });
|
||||
}
|
||||
pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入履约中${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list');
|
||||
void notifyBySubscribe('orderStatus', {
|
||||
userId: buyer,
|
||||
@@ -2244,7 +2256,7 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
// 沙箱仅允许推进到「已支付待履约」的合法状态,严禁客户端任意指定终态或退款态
|
||||
const requested = String(req.body?.targetStatus || '履约中');
|
||||
const requested = String(req.body?.targetStatus || (Number(own.expertId) ? '履约中' : '待接单'));
|
||||
const allowedSandboxStatuses = ['履约中', '待接单'];
|
||||
if (!allowedSandboxStatuses.includes(requested)) {
|
||||
return res.status(400).json({ code: 40000, message: `非法的目标订单状态: ${requested}` });
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('第十九批支付金额完整性闸门', () => {
|
||||
|
||||
it('订单归属解析必须携带订单金额', () => {
|
||||
const start = repoCode.indexOf('async getOrderOwnership(');
|
||||
const block = repoCode.slice(start, start + 900);
|
||||
const block = repoCode.slice(start, start + 1000);
|
||||
expect(block).toContain('o.amount');
|
||||
expect(block).toContain('amount: Number(r.amount)');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第九十五批:平台派单(发需求单)闭环闸门。
|
||||
* 背景:抢单池只查 status='待接单',但下单流程全是「指定大神 → 支付 → 履约中」,
|
||||
* 没有任何流程产出待接单订单,导致雷达/接单大厅永远是空池。
|
||||
*/
|
||||
describe('第九十五批平台派单闭环', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
||||
const repo = read('server/src/db/repository.ts');
|
||||
const indexCode = read('server/src/index.ts');
|
||||
const createPage = read('pwuniapp/pages/order/create.vue');
|
||||
|
||||
it('平台派单订单 expertId=0,余额支付后直接进入待接单池', () => {
|
||||
expect(repo).toContain('const isMarketplace = !Number(data.expertId)');
|
||||
expect(repo).toContain("const expertId = isMarketplace ? 0 : Number(data.expertId)");
|
||||
expect(repo).toContain("isMarketplace ? '待接单' : '履约中'");
|
||||
});
|
||||
|
||||
it('平台派单预算由买家出价但受服务端上下限约束', () => {
|
||||
expect(repo).toContain('hourlyRate = Math.min(2000, Math.max(1, Number(data.hourlyRate) || 35))');
|
||||
});
|
||||
|
||||
it('微信支付确认后:无大神订单转入待接单而不是履约中', () => {
|
||||
expect(indexCode).toContain("const paidStatus = isMarketplace ? '待接单' : '履约中'");
|
||||
expect(indexCode).toContain('已进入派单池,等待附近大神接单');
|
||||
expect(indexCode).toContain('30 分钟内无人接单将自动全额退款');
|
||||
});
|
||||
|
||||
it('抢单后由认领大神写入 expert_id 并进入履约中', () => {
|
||||
expect(repo).toContain("SET status = '履约中', expert_id = ?, companion_name = ?");
|
||||
expect(repo).toContain("AND status = '待接单'");
|
||||
});
|
||||
|
||||
it('下单页提供「平台派单」模式并提交 expertId=0', () => {
|
||||
expect(createPage).toContain("const orderMode = ref('EXPERT')");
|
||||
expect(createPage).toContain('platformCategories');
|
||||
expect(createPage).toContain('expertId: isPlatform ? 0 : expert.value.id');
|
||||
expect(createPage).toContain('平台派单需求');
|
||||
});
|
||||
|
||||
it('平台派单时不会沿用指定大神页的默认地点', () => {
|
||||
expect(createPage).toContain('const switchMode = (mode) =>');
|
||||
expect(createPage).toContain("meetVenue.value = '';");
|
||||
});
|
||||
|
||||
it('订单 INSERT 的列数必须与占位符数量一致(含 paid_at/created_at 两个表达式值)', () => {
|
||||
const start = repo.indexOf('INSERT INTO orders');
|
||||
const end = repo.indexOf('`, [', start);
|
||||
const seg = repo.slice(start, end);
|
||||
const columns = seg
|
||||
.slice(seg.indexOf('(') + 1, seg.indexOf(')'))
|
||||
.split(',')
|
||||
.filter((s) => s.trim()).length;
|
||||
const values = seg.slice(seg.indexOf('VALUES'));
|
||||
const placeholders = (values.replace(/\$\{[^}]*\}/g, '').match(/\?/g) || []).length;
|
||||
expect(columns).toBe(24);
|
||||
expect(placeholders + 2).toBe(columns);
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,8 @@ describe('第二十三批结算状态闸门', () => {
|
||||
|
||||
it('订单初始状态必须由服务端按支付方式决定', () => {
|
||||
const start = repoCode.indexOf('async createOrder(');
|
||||
const block = repoCode.slice(start, start + 4200);
|
||||
expect(block).toContain("const status = payType === 'BALANCE' ? '履约中' : '待付款';");
|
||||
const block = repoCode.slice(start, start + 5200);
|
||||
expect(block).toContain("? (isMarketplace ? '待接单' : '履约中')");
|
||||
expect(block).not.toContain('const status = data.status ||');
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户