feat(order): 平台派单闭环(发需求单进入待接单池)

- 下单页新增「指定大神 / 平台派单」模式:平台派单填品类、预算、地点/选点,expertId=0

- 服务端:平台派单单据支付后进入待接单池(余额支付即时入池,微信支付确认后入池),预算由买家出价但受 1~2000 元/小时约束

- 抢单由认领大神写入 expert_id 并转履约中;30 分钟无人接单沿用自动全额退款

- 修复订单 INSERT 占位符少一个导致下单报 Column count doesn't match 的问题,并新增列数/占位符一致性护栏

- 111 套件/737 用例全绿;线上 1 元实测:下单→待接单→池内 640m,测试数据已回滚
这个提交包含在:
xcbyy
2026-10-21 15:16:03 +08:00
父节点 5538d8ce7e
当前提交 77aa4cb50b
共有 6 个文件被更改,包括 257 次插入 和 32 次删除

查看文件

@@ -1458,30 +1458,37 @@ 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;
try {
const comp = await this.resolveCompanionRow(expertId);
if (comp) {
const compRate = Number(comp.hourly_rate) || 0;
let valid = compRate > 0 && Number(hourlyRate) === compRate;
if (!valid) {
const services = await this.getExpertServices(comp.id).catch(() => []);
valid = Array.isArray(services) && services.some((s: any) => Number(s.price) === Number(hourlyRate));
}
if (!valid && compRate > 0) {
hourlyRate = compRate;
if (isMarketplace) {
hourlyRate = Math.min(2000, Math.max(1, Number(data.hourlyRate) || 35));
} else {
try {
const comp = await this.resolveCompanionRow(expertId);
if (comp) {
const compRate = Number(comp.hourly_rate) || 0;
let valid = compRate > 0 && Number(hourlyRate) === compRate;
if (!valid) {
const services = await this.getExpertServices(comp.id).catch(() => []);
valid = Array.isArray(services) && services.some((s: any) => Number(s.price) === Number(hourlyRate));
}
if (!valid && compRate > 0) {
hourlyRate = compRate;
}
}
} catch (e) {
logServerError('createOrder.validateRate', e);
}
} catch (e) {
logServerError('createOrder.validateRate', e);
}
// 优惠券:优先使用"我的券"(userCouponId,校验归属/门槛/类目/有效期),否则退回面额匹配平台有效券
@@ -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 ||');
});
});