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/取消回退)
这个提交包含在:
@@ -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 {
|
||||
|
||||
在新工单中引用
屏蔽一个用户