feat(coupon): 优惠券中心(领取)+ 我的券 + 下单核销/回退

- 新表 user_coupons(快照面额/门槛/类目/有效期)+ orders.user_coupon_id
- 仓储:getCouponsWithClaimed/claimCoupon/getUserCoupons/resolveUserCoupon/consumeUserCoupon/releaseUserCouponByOrder
- 接口:POST /api/marketing/coupons/:id/claim(同券仅一次、受总量限制)、GET /api/marketing/my-coupons(自动标记过期)、券中心带 claimed 标识
- 下单:createOrder 支持 userCouponId(校验归属/门槛/类目/有效期,服务端算抵扣,与订单同事务占用)
- 回退:买家取消/大神拒单/超时退款/仲裁退款 均把券退回为 UNUSED
- 小程序:新增 pages/user/coupons(券中心+我的券)、个人中心入口(可用张数)、下单改为选择"我的券"并传 userCouponId
- 回归 94 套件/666 用例全绿;冒烟 9/9(领取/重复领取409/不存在404/下单抵扣100-10=90/取消回退)
这个提交包含在:
xcbyy
2026-10-19 15:05:56 +08:00
父节点 500aac306e
当前提交 6576c9e4be
共有 11 个文件被更改,包括 794 次插入 和 34 次删除

查看文件

@@ -154,7 +154,8 @@ export async function initDatabase(): Promise<void> {
"ALTER TABLE orders ADD COLUMN finish_applied_at DATETIME NULL AFTER paid_at",
"ALTER TABLE orders ADD COLUMN refund_amount DECIMAL(10,2) DEFAULT 0.00 AFTER coupon_discount",
"ALTER TABLE orders ADD COLUMN claimed_by INT NULL AFTER expert_id",
"ALTER TABLE orders ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by"
"ALTER TABLE orders ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by",
"ALTER TABLE orders ADD COLUMN user_coupon_id VARCHAR(64) NULL AFTER coupon_discount"
];
for (const colSql of extraOrderCols) {
try {
@@ -660,6 +661,26 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 用户已领取的优惠券(券中心领取 → 下单核销 → 取消/退款回退)
await conn.query(`
CREATE TABLE IF NOT EXISTS user_coupons (
id VARCHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
coupon_id INT NOT NULL,
name VARCHAR(64) NOT NULL DEFAULT '',
discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
min_spend DECIMAL(10,2) NOT NULL DEFAULT 0.00,
category_limit VARCHAR(64) DEFAULT 'ALL',
status VARCHAR(16) NOT NULL DEFAULT 'UNUSED',
order_id VARCHAR(64) NULL,
claimed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expired_at DATETIME NULL,
used_at DATETIME NULL,
INDEX idx_uc_user_status (user_id, status),
INDEX idx_uc_coupon_user (coupon_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 一次性清理:历史版本会自动为大神生成 3 个演示服务包(现已改为大神自助维护),删除演示数据
try {
await conn.query(`

查看文件

@@ -1267,6 +1267,7 @@ export class MysqlRepository {
if (refundAmt > 0) {
await this.refundOrderPayment(pool as any, ord, Math.round(refundAmt * 100), '超时未接单退款', '超时未接单自动退款', 'RFND_TMO');
}
await this.releaseCouponWithConn(pool, ord.id);
refundedCount += 1;
} catch (e) {
// 退款失败(如微信原路退款异常):回滚状态,等待下次扫描重试,避免"已退款但未到账"
@@ -1385,6 +1386,7 @@ export class MysqlRepository {
durationHours?: number;
amount?: number;
couponDiscount?: number;
userCouponId?: string;
payType?: string;
location?: string;
expenseBurden?: string;
@@ -1428,10 +1430,24 @@ export class MysqlRepository {
logServerError('createOrder.validateRate', e);
}
// 优惠券折扣必须命中平台启用中的有效券(面额 / 门槛 / 品类),否则忽略为 0
// 优惠券:优先使用"我的券"(userCouponId,校验归属/门槛/类目/有效期),否则退回面额匹配平台有效券
const baseAmount = Number((hourlyRate * durationHours).toFixed(2));
const rawDiscount = Math.max(0, Number(data.couponDiscount || 0));
const couponDiscount = rawDiscount > 0 ? await this.resolveCouponDiscount(rawDiscount, track, baseAmount) : 0;
let couponDiscount = 0;
let userCouponId = '';
if (data.userCouponId) {
const resolved = await this.resolveUserCoupon(String(data.userCouponId), buyerId, track, baseAmount);
if (!resolved) {
const err: any = new Error('所选优惠券不可用(不满足使用门槛/类目,或已使用/已过期)');
err.status = 400;
err.code = 40000;
throw err;
}
couponDiscount = resolved.discount;
userCouponId = resolved.userCouponId;
} else {
const rawDiscount = Math.max(0, Number(data.couponDiscount || 0));
couponDiscount = rawDiscount > 0 ? await this.resolveCouponDiscount(rawDiscount, track, baseAmount) : 0;
}
// 订单金额由服务端权威计算,杜绝客户端伪造低价下单
const amount = Number(Math.max(baseAmount - couponDiscount, 0.01).toFixed(2));
@@ -1474,17 +1490,25 @@ export class MysqlRepository {
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,
coupon_discount, user_coupon_id, pay_type,
location, 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,
expenseBurden, hourlyRate, durationHours, amount,
couponDiscount, payType,
couponDiscount, userCouponId || null, payType,
location, verifyCode, remark, status
]);
// 占用"我的券"(与订单同事务,避免下单成功但券未核销导致重复使用)
if (userCouponId) {
await conn.query(
"UPDATE user_coupons SET status = 'USED', order_id = ?, used_at = NOW() WHERE id = ? AND status = 'UNUSED'",
[id, userCouponId]
);
}
// 记一笔平台订单进账流水
const finId = `fin_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const recordNo = `TX${Date.now()}${Math.floor(Math.random() * 100)}`;
@@ -1571,8 +1595,10 @@ export class MysqlRepository {
const refundAmount = Number(order.amount || 0);
await conn.query('UPDATE orders SET status = "已退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]);
await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '买家取消订单', '买家取消订单退款', 'RFND_CX');
await this.releaseCouponWithConn(conn, order.id);
} else {
await conn.query('UPDATE orders SET status = "已取消" WHERE id = ?', [order.id]);
await this.releaseCouponWithConn(conn, order.id);
}
await conn.commit();
@@ -1674,6 +1700,7 @@ export class MysqlRepository {
if (refundAmount > 0) {
await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '大神拒单', '大神拒单自动退款', 'RFND_REJ');
}
await this.releaseCouponWithConn(conn, order.id);
await conn.commit();
const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]);
@@ -2554,6 +2581,165 @@ export class MysqlRepository {
}
}
/** 券中心:返回启用中的优惠券,并标记当前用户是否已领取 */
async getCouponsWithClaimed(userId: number, categoryLimit?: string): Promise<any[]> {
const list = await this.getCoupons(categoryLimit);
const uid = Number(userId) || 0;
let claimedIds = new Set<number>();
if (uid > 0) {
try {
const [rows]: any = await pool.query('SELECT coupon_id FROM user_coupons WHERE user_id = ?', [uid]);
claimedIds = new Set((rows || []).map((r: any) => Number(r.coupon_id)));
} catch (_) {}
}
return list.map((c: any) => ({ ...c, claimed: claimedIds.has(Number(c.id)) }));
}
/** 领取优惠券(同一用户同一券仅可领一次;受 total_count 限制) */
async claimCoupon(userId: number, couponId: number): Promise<any> {
const uid = Number(userId) || 0;
const cid = Number(couponId) || 0;
const fail = (msg: string, status: number, code: number) => {
const e: any = new Error(msg);
e.status = status;
e.code = code;
return e;
};
if (uid <= 0) throw fail('请先登录后再领取优惠券', 401, 40100);
if (cid <= 0) throw fail('优惠券参数不合法', 400, 40000);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [cRows]: any = await conn.query('SELECT * FROM coupons WHERE id = ? AND status = 1 FOR UPDATE', [cid]);
if (!cRows || cRows.length === 0) throw fail('优惠券不存在或已下架', 404, 40400);
const c = cRows[0];
const [own]: any = await conn.query(
'SELECT id FROM user_coupons WHERE user_id = ? AND coupon_id = ? LIMIT 1',
[uid, cid]
);
if (own && own.length > 0) throw fail('您已领取过该优惠券', 409, 40900);
const total = Number(c.total_count) || 0;
if (total > 0 && Number(c.received_count) >= total) throw fail('该优惠券已被领完', 400, 40000);
const id = `uc_${cid}_${uid}_${Date.now()}`;
const validDays = Number(c.valid_days) || 7;
await conn.query(
`INSERT INTO user_coupons
(id, user_id, coupon_id, name, discount_amount, min_spend, category_limit, status, claimed_at, expired_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'UNUSED', NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))`,
[id, uid, cid, String(c.name), Number(c.discount_amount), Number(c.min_spend), String(c.category_limit || 'ALL'), validDays]
);
await conn.query('UPDATE coupons SET received_count = received_count + 1 WHERE id = ?', [cid]);
await conn.commit();
return {
id,
couponId: cid,
name: String(c.name),
discountAmount: Number(c.discount_amount),
minSpend: Number(c.min_spend),
categoryLimit: String(c.category_limit || 'ALL'),
validDays,
};
} catch (e) {
try { await conn.rollback(); } catch (_) {}
throw e;
} finally {
conn.release();
}
}
/** 我的优惠券(读取前顺手把已过期的标记为 EXPIRED) */
async getUserCoupons(userId: number, status?: string): Promise<any[]> {
const uid = Number(userId) || 0;
if (uid <= 0) return [];
try {
await pool.query(
"UPDATE user_coupons SET status = 'EXPIRED' WHERE user_id = ? AND status = 'UNUSED' AND expired_at IS NOT NULL AND expired_at < NOW()",
[uid]
);
} catch (_) {}
let sql = `
SELECT id, coupon_id as couponId, name, discount_amount as discountAmount,
min_spend as minSpend, category_limit as categoryLimit, status,
order_id as orderId, claimed_at as claimedAt, expired_at as expiredAt, used_at as usedAt
FROM user_coupons
WHERE user_id = ?
`;
const params: any[] = [uid];
if (status && ['UNUSED', 'USED', 'EXPIRED'].includes(status)) {
sql += ' AND status = ?';
params.push(status);
}
sql += ' ORDER BY (status = "UNUSED") DESC, discount_amount DESC, claimed_at DESC LIMIT 200';
const [rows]: any = await pool.query(sql, params);
return rows;
}
/** 校验"我的券"是否可用于本单,并返回抵扣金额 */
async resolveUserCoupon(
userCouponId: string,
userId: number,
track: string,
baseAmountYuan: number
): Promise<{ userCouponId: string; discount: number } | null> {
try {
const [rows]: any = await pool.query(
'SELECT * FROM user_coupons WHERE id = ? AND user_id = ? LIMIT 1',
[String(userCouponId), Number(userId) || 0]
);
if (!rows || rows.length === 0) return null;
const c = rows[0];
if (String(c.status) !== 'UNUSED') return null;
if (c.expired_at && new Date(c.expired_at).getTime() < Date.now()) return null;
if (Number(c.min_spend) > Number(baseAmountYuan)) return null;
const limit = String(c.category_limit || 'ALL');
if (limit !== 'ALL' && limit !== String(track)) return null;
const discount = Math.min(Number(c.discount_amount) || 0, Number(baseAmountYuan) || 0);
if (discount <= 0) return null;
return { userCouponId: String(c.id), discount: Number(discount.toFixed(2)) };
} catch (e) {
logServerError('resolveUserCoupon', e);
return null;
}
}
/** 核销(下单时占用) */
async consumeUserCoupon(userCouponId: string, orderId: string): Promise<void> {
try {
await pool.query(
"UPDATE user_coupons SET status = 'USED', order_id = ?, used_at = NOW() WHERE id = ? AND status = 'UNUSED'",
[String(orderId), String(userCouponId)]
);
} catch (e) {
logServerError('consumeUserCoupon', e);
}
}
/** 回退(取消/拒单/退款时把券还给用户;可传入事务连接保证与退款同事务) */
private async releaseCouponWithConn(connOrPool: any, orderId: string): Promise<void> {
if (!orderId) return;
try {
await connOrPool.query(
"UPDATE user_coupons SET status = 'UNUSED', order_id = NULL, used_at = NULL WHERE order_id = ? AND status = 'USED'",
[String(orderId)]
);
} catch (e) {
logServerError('releaseCouponWithConn', e);
}
}
async releaseUserCouponByOrder(orderId: string): Promise<void> {
try {
await pool.query(
"UPDATE user_coupons SET status = 'UNUSED', order_id = NULL, used_at = NULL WHERE order_id = ? AND status = 'USED'",
[String(orderId)]
);
} catch (e) {
logServerError('releaseUserCouponByOrder', e);
}
}
async getCoupons(categoryLimit?: string): Promise<any[]> {
let sql = `
SELECT
@@ -2563,8 +2749,7 @@ export class MysqlRepository {
created_at as createdAt, updated_at as updatedAt
FROM coupons
WHERE status = 1
`;
const params: any[] = [];
`; const params: any[] = [];
if (categoryLimit && categoryLimit !== 'ALL') {
sql += ' AND (category_limit = "ALL" OR category_limit = ?)';
params.push(categoryLimit);
@@ -2895,6 +3080,7 @@ export class MysqlRepository {
if (refundAmount > 0) {
await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '平台仲裁退款', '仲裁退款', 'RFND_ARB');
}
await this.releaseCouponWithConn(conn, order.id);
// 4. 联动扣减违规大神评分
const expertId = Number(order.expert_id);

查看文件

@@ -1728,13 +1728,51 @@ app.delete('/api/admin/recharge-tiers/:id', async (req: Request, res: Response)
app.get('/api/marketing/coupons', async (req: Request, res: Response) => {
try {
const category = req.query.category ? String(req.query.category) : undefined;
const list = await repo.getCoupons(category);
const uid = Number(req.query.userId || req.headers['x-user-id'] || 0) || 0;
const list = await repo.getCouponsWithClaimed(uid, category);
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 领取优惠券(同一用户同一券仅可领一次)
app.post('/api/marketing/coupons/:id/claim', async (req: Request, res: Response) => {
try {
const couponId = Number(req.params.id);
const uid = resolveSecureUserId(
req,
Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0),
'领取优惠券'
);
const claimed = await repo.claimCoupon(uid, couponId);
res.json({ code: 20000, data: claimed, message: '优惠券领取成功' });
} catch (err: any) {
const statusCode = err.status || 400;
const errCode = err.code || 40000;
res.status(statusCode).json({ code: errCode, message: err.message });
}
});
// 我的优惠券(status: UNUSED | USED | EXPIRED,缺省返回全部)
app.get('/api/marketing/my-coupons', async (req: Request, res: Response) => {
try {
const uid = resolveSecureUserId(
req,
Number(req.query.userId || req.headers['x-user-id'] || 0),
'查看我的优惠券'
);
const status = req.query.status ? String(req.query.status) : undefined;
const list = await repo.getUserCoupons(uid, status);
const usable = list.filter((c: any) => c.status === 'UNUSED').length;
res.json({ code: 20000, data: { list, usable } });
} catch (err: any) {
const statusCode = err.status || 400;
const errCode = err.code || 40000;
res.status(statusCode).json({ code: errCode, message: err.message });
}
});
// 管理后台:获取全部优惠券
app.get('/api/admin/coupons', async (_req: Request, res: Response) => {
try {