feat(order): support balance payment, dynamic marketing coupon recommendation, and quick activity picker

这个提交包含在:
xcbyy
2026-10-10 15:37:41 +08:00
父节点 872af22c20
当前提交 7ac81035b4
共有 7 个文件被更改,包括 559 次插入 和 18 次删除

查看文件

@@ -120,7 +120,9 @@ export async function initDatabase(): Promise<void> {
"ALTER TABLE orders ADD COLUMN expert_avatar TEXT AFTER companion_name",
"ALTER TABLE orders ADD COLUMN service_title VARCHAR(255) DEFAULT '同城陪伴服务' AFTER track",
"ALTER TABLE orders ADD COLUMN verify_code VARCHAR(8) AFTER location",
"ALTER TABLE orders ADD COLUMN remark VARCHAR(255) AFTER verify_code"
"ALTER TABLE orders ADD COLUMN remark VARCHAR(255) AFTER verify_code",
"ALTER TABLE orders ADD COLUMN pay_type VARCHAR(32) DEFAULT 'WECHAT' AFTER status",
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount"
];
for (const colSql of extraOrderCols) {
try {

查看文件

@@ -617,6 +617,8 @@ export class MysqlRepository {
hourly_rate as hourlyRate,
duration_hours as durationHours,
amount,
COALESCE(coupon_discount, 0.00) as couponDiscount,
COALESCE(pay_type, 'WECHAT') as payType,
location,
verify_code as verifyCode,
remark,
@@ -639,6 +641,8 @@ export class MysqlRepository {
hourlyRate?: number;
durationHours?: number;
amount?: number;
couponDiscount?: number;
payType?: string;
location?: string;
expenseBurden?: string;
remark?: string;
@@ -656,6 +660,8 @@ export class MysqlRepository {
const hourlyRate = Number(data.hourlyRate) || 35;
const durationHours = Number(data.durationHours) || 2;
const amount = Number(data.amount) || hourlyRate * durationHours;
const couponDiscount = Number(data.couponDiscount || 0);
const payType = data.payType === 'BALANCE' ? 'BALANCE' : 'WECHAT';
const location = data.location || '朝阳大悦城';
const expenseBurden = data.expenseBurden || 'AA制各自付';
const verifyCode = String(Math.floor(1000 + Math.random() * 9000));
@@ -665,27 +671,52 @@ export class MysqlRepository {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// 如果是余额支付,先锁行校验扣减用户钱包余额
if (payType === 'BALANCE') {
const [uRows]: any = await conn.query('SELECT balance FROM users WHERE id = ? FOR UPDATE', [buyerId]);
if (!uRows || uRows.length === 0) {
throw new Error('支付账户不存在');
}
const userBalance = Number(uRows[0].balance || 0);
if (userBalance < amount) {
throw new Error(`钱包余额不足 (当前可用 ¥${userBalance.toFixed(2)},需支付 ¥${amount.toFixed(2)})`);
}
const newBalance = userBalance - amount;
await conn.query('UPDATE users SET balance = ? WHERE id = ?', [newBalance, buyerId]);
// 记一笔钱包扣款流水
const walletFinId = `fin_bal_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const walletRecNo = `WB${Date.now()}${Math.floor(Math.random() * 100)}`;
await conn.query(`
INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)
VALUES (?, ?, '钱包余额支付订单', ?, 'OUT', ?, ?)
`, [walletFinId, walletRecNo, amount.toFixed(2), `用户 ${buyerId} (${companionName})`, id]);
}
await conn.query(`
INSERT INTO orders (
id, buyer_id, expert_id, order_no, client_name,
companion_name, expert_avatar, track, service_title,
expense_burden, hourly_rate, duration_hours, amount,
coupon_discount, pay_type,
location, verify_code, remark, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
`, [
id, buyerId, expertId, orderNo, clientName,
companionName, expertAvatar, track, serviceTitle,
expenseBurden, hourlyRate, durationHours, amount,
couponDiscount, payType,
location, verifyCode, remark, status
]);
// 记一笔进账流水
// 记一笔平台订单进账流水
const finId = `fin_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const recordNo = `TX${Date.now()}${Math.floor(Math.random() * 100)}`;
await conn.query(`
INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)
VALUES (?, ?, '服务预约订单付款', ?, 'IN', ?, ?)
`, [finId, recordNo, amount.toFixed(2), clientName, id]);
VALUES (?, ?, ?, ?, 'IN', ?, ?)
`, [finId, recordNo, payType === 'BALANCE' ? '服务预约订单付款 (钱包余额)' : '服务预约订单付款 (微信支付)', amount.toFixed(2), clientName, id]);
await conn.commit();
@@ -703,6 +734,8 @@ export class MysqlRepository {
hourlyRate,
durationHours,
amount,
couponDiscount,
payType,
location,
verifyCode,
remark,

查看文件

@@ -263,5 +263,60 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
expect(createActContent).toContain('activity_rules');
});
});
describe('8. 订单结算体系与前端交互人性化专项验证 (支付方式二选一、动态优惠券、组局快捷时段与商圈选点)', () => {
it('MySQL Schema 必须包含 pay_type 与 coupon_discount 字段升级补丁', () => {
const mysqlPath = path.join(rootDir, 'server', 'src', 'db', 'mysql.ts');
const content = fs.readFileSync(mysqlPath, 'utf-8');
expect(content).toContain('ALTER TABLE orders ADD COLUMN pay_type');
expect(content).toContain('ALTER TABLE orders ADD COLUMN coupon_discount');
});
it('Repository createOrder 必须支持余额支付原子事务校验、扣减余额及财务流水记录', () => {
const repoPath = path.join(rootDir, 'server', 'src', 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain("data.payType === 'BALANCE'");
expect(content).toContain('SELECT balance FROM users WHERE id = ? FOR UPDATE');
expect(content).toContain('const newBalance = userBalance - amount');
expect(content).toContain('UPDATE users SET balance = ? WHERE id = ?');
expect(content).toContain('钱包余额支付订单');
expect(content).toContain("INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)");
});
it('小程序订单创建页面必须支持钱包余额与微信支付二选一并动态拉取营销优惠券', () => {
const orderCreatePath = path.join(rootDir, 'pwuniapp', 'pages', 'order', 'create.vue');
const content = fs.readFileSync(orderCreatePath, 'utf-8');
expect(content).toContain('payType');
expect(content).toContain("'BALANCE'");
expect(content).toContain("'WECHAT'");
expect(content).toContain('getMarketingCoupons');
expect(content).toContain('couponDiscount');
});
it('小程序活动发起页面必须包含快捷时段胶囊与示范商圈及地图选点交互', () => {
const actCreatePath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'create.vue');
const content = fs.readFileSync(actCreatePath, 'utf-8');
expect(content).toContain('presetTimeOptions');
expect(content).toContain('recommendPublicZones');
expect(content).toContain('handleChooseLocation');
expect(content).toContain('uni.chooseLocation');
});
it('所有新增代码与测试套件严禁包含任何 Emoji 字符', () => {
const pathsToCheck = [
path.join(rootDir, 'server', 'src', 'db', 'mysql.ts'),
path.join(rootDir, 'server', 'src', 'db', 'repository.ts'),
path.join(rootDir, 'pwuniapp', 'pages', 'order', 'create.vue'),
path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'create.vue'),
path.join(rootDir, 'pwuniapp', 'common', 'icons.js'),
path.join(rootDir, 'pwuniapp', 'common', 'api.js')
];
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
for (const p of pathsToCheck) {
const text = fs.readFileSync(p, 'utf-8');
expect(emojiRegex.test(text)).toBe(false);
}
});
});
});