From 6576c9e4be010286d34b736fb69880afd32f3191 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Mon, 19 Oct 2026 15:05:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(coupon):=20=E4=BC=98=E6=83=A0=E5=88=B8?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=EF=BC=88=E9=A2=86=E5=8F=96=EF=BC=89+=20?= =?UTF-8?q?=E6=88=91=E7=9A=84=E5=88=B8=20+=20=E4=B8=8B=E5=8D=95=E6=A0=B8?= =?UTF-8?q?=E9=94=80/=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新表 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/取消回退) --- docs/部署验收与1分钱支付闭环.md | 6 + pwuniapp/common/api.js | 29 +- pwuniapp/pages.json | 10 + pwuniapp/pages/order/create.vue | 35 +-- pwuniapp/pages/user/coupons.vue | 400 ++++++++++++++++++++++++++ pwuniapp/pages/user/index.vue | 31 +- server/src/db/mysql.ts | 23 +- server/src/db/repository.ts | 202 ++++++++++++- server/src/index.ts | 40 ++- server/tests/content-security.spec.ts | 6 +- server/tests/user-coupon.spec.ts | 46 +++ 11 files changed, 794 insertions(+), 34 deletions(-) create mode 100644 pwuniapp/pages/user/coupons.vue create mode 100644 server/tests/user-coupon.spec.ts diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md index 224f8b4..9498723 100644 --- a/docs/部署验收与1分钱支付闭环.md +++ b/docs/部署验收与1分钱支付闭环.md @@ -150,6 +150,12 @@ MYSQL_DATABASE=peiwan - `40037` 排查:点后台「**拉取小程序模板列表(核对ID)**」,会显示当前 appId(脱敏)与其下真实存在的订阅消息模板;若后台填写的 ID 不在列表中,即为 appId 与建模板的小程序不一致(或用了公众号/服务号的模板ID)。用列表里的真实 ID 覆盖即可。 6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。 +### 优惠券中心与"我的券"(已内置) +- 券中心:`GET /api/marketing/coupons`(带 `userId` 时返回 `claimed` 标识)、`POST /api/marketing/coupons/:id/claim` 领取(同一用户同一券仅可领一次,受 `total_count` 限制); +- 我的券:`GET /api/marketing/my-coupons?status=UNUSED|USED|EXPIRED`(读取时自动把过期券标记为 EXPIRED); +- 下单:`POST /api/orders` 支持 `userCouponId`(校验归属/门槛/类目/有效期后服务端计算抵扣,并在同事务占用); +- 回退:买家取消 / 大神拒单 / 超时退款 / 仲裁退款 都会把券**退回**给用户(`user_coupons.status` 回到 UNUSED)。 + ### 大神服务包自助维护(已内置) - 大神工作台可**自助新增/编辑/删除/上下架**服务包(`POST|PUT|DELETE /api/companions/:id/services[...]`,仅本人大神或管理员); - 校验:名称必填 ≤40 字、单价须落在平台定价规范区间(`expert_rules` 的 min/maxHourlyRate)、说明 ≤200 字、通过内容安全检测、每人上限 20 个; diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 1000693..f7ee126 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -759,13 +759,17 @@ export async function getRechargeTiers() { } /** - * 获取可用优惠券列表 (支持品类过滤) + * 获取可用优惠券列表 (支持品类过滤;带 userId 时会返回 claimed 标识) */ export async function getMarketingCoupons(category) { try { - const query = category ? `?category=${category}` : ''; + const uid = normalizeUserId(); + const qs = []; + if (category) qs.push(`category=${category}`); + if (uid) qs.push(`userId=${uid}`); + const query = qs.length ? `?${qs.join('&')}` : ''; const res = await http.get(`/marketing/coupons${query}`); - if (Array.isArray(res) && res.length > 0) { + if (Array.isArray(res)) { return res; } } catch (err) { @@ -774,6 +778,25 @@ export async function getMarketingCoupons(category) { return null; } +/** + * 领取优惠券 + */ +export async function claimCoupon(couponId, userId) { + const uid = normalizeUserId(userId); + const res = await http.post(`/marketing/coupons/${Number(couponId)}/claim`, { userId: uid }); + return res; +} + +/** + * 我的优惠券(status: UNUSED | USED | EXPIRED,缺省返回全部) + */ +export async function getMyCoupons(userId, status) { + const uid = normalizeUserId(userId); + const query = status ? `?userId=${uid}&status=${status}` : `?userId=${uid}`; + const res = await http.get(`/marketing/my-coupons${query}`); + return res || { list: [], usable: 0 }; +} + diff --git a/pwuniapp/pages.json b/pwuniapp/pages.json index 03c18c9..5195144 100644 --- a/pwuniapp/pages.json +++ b/pwuniapp/pages.json @@ -211,6 +211,16 @@ "backgroundColor": "#0A0F1D" } }, + { + "path": "pages/user/coupons", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "优惠券", + "navigationBarBackgroundColor": "#0A0F1D", + "navigationBarTextStyle": "white", + "backgroundColor": "#0A0F1D" + } + }, { "path": "pages/activity/create", "style": { diff --git a/pwuniapp/pages/order/create.vue b/pwuniapp/pages/order/create.vue index 7ec3555..4afe7d6 100644 --- a/pwuniapp/pages/order/create.vue +++ b/pwuniapp/pages/order/create.vue @@ -239,7 +239,7 @@ + + diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index 536d8d9..ebbf65f 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -149,6 +149,12 @@ 官方在线客服 + + + 我的优惠券 + {{ couponUsable }}张可用 + + 消息通知 @@ -357,7 +363,7 @@ import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js'; import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js'; import { getNavBarInfo } from '@/common/nav.js'; -import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode, getNotifications } from '@/common/api.js'; +import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode, getNotifications, getMyCoupons } from '@/common/api.js'; import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscribe.js'; const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; @@ -391,6 +397,28 @@ const openMyReportsModal = () => { const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1'); const noticeSubscribe = ref(uni.getStorageSync('notice_subscribe') === '1'); const noticeUnread = ref(0); +const couponUsable = ref(0); + +const openCoupons = () => { + uni.navigateTo({ + url: '/pages/user/coupons', + fail: () => { + uni.showToast({ title: '页面打开失败,请稍后重试', icon: 'none' }); + } + }); +}; + +const loadCouponUsable = async () => { + const uid = uni.getStorageSync('userId') || 0; + if (!uid) return; + try { + const res = await getMyCoupons(uid, 'UNUSED'); + const list = Array.isArray(res?.list) ? res.list : []; + couponUsable.value = Number(res?.usable ?? list.length) || 0; + } catch (_) { + couponUsable.value = 0; + } +}; const cacheSizeText = ref('1.25 MB'); // 分销推广管理 @@ -651,6 +679,7 @@ onShow(() => { privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1'; loadNoticeUnread(); + loadCouponUsable(); if (isLoggedIn.value) { loadFavorites(); loadMyReports(); diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index a5a1ba7..4e15213 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -154,7 +154,8 @@ export async function initDatabase(): Promise { "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 { ) 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(` diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 951a759..12a7d28 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -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 { + const list = await this.getCoupons(categoryLimit); + const uid = Number(userId) || 0; + let claimedIds = new Set(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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); diff --git a/server/src/index.ts b/server/src/index.ts index 2fba13a..d412df7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index 53aceff..2a9d881 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 const raw = fs.readFileSync(pagesJsonPath, 'utf-8'); const config = JSON.parse(raw); expect(Array.isArray(config.pages)).toBe(true); - expect(config.pages.length).toBe(27); + expect(config.pages.length).toBe(28); for (const pageItem of config.pages) { const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`); @@ -289,8 +289,8 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 expect(content).toContain('payType'); expect(content).toContain("'BALANCE'"); expect(content).toContain("'WECHAT'"); - expect(content).toContain('getMarketingCoupons'); - expect(content).toContain('couponDiscount'); + expect(content).toContain('getMyCoupons'); + expect(content).toContain('userCouponId'); }); it('小程序活动发起页面必须包含快捷时段胶囊与示范商圈及地图选点交互', () => { diff --git a/server/tests/user-coupon.spec.ts b/server/tests/user-coupon.spec.ts new file mode 100644 index 0000000..9f8c327 --- /dev/null +++ b/server/tests/user-coupon.spec.ts @@ -0,0 +1,46 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第七十八批:优惠券中心(领取)+ 我的券 + 下单核销/回退。 + */ +describe('第七十八批优惠券中心闸门', () => { + const root = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8'); + const repo = fs.readFileSync(path.join(root, 'server/src/db/repository.ts'), 'utf-8'); + const mysql = fs.readFileSync(path.join(root, 'server/src/db/mysql.ts'), 'utf-8'); + const api = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8'); + const pagesJson = fs.readFileSync(path.join(root, 'pwuniapp/pages.json'), 'utf-8'); + const userPage = fs.readFileSync(path.join(root, 'pwuniapp/pages/user/index.vue'), 'utf-8'); + const createPage = fs.readFileSync(path.join(root, 'pwuniapp/pages/order/create.vue'), 'utf-8'); + + it('建表与仓储具备用户券能力', () => { + expect(mysql).toContain('CREATE TABLE IF NOT EXISTS user_coupons'); + expect(mysql).toContain('ALTER TABLE orders ADD COLUMN user_coupon_id'); + expect(repo).toContain('async claimCoupon('); + expect(repo).toContain('async getUserCoupons('); + expect(repo).toContain('async resolveUserCoupon('); + expect(repo).toContain('async releaseCouponWithConn('); + }); + + it('领取/我的券接口就绪,券中心带已领取标记', () => { + expect(indexCode).toContain("app.post('/api/marketing/coupons/:id/claim'"); + expect(indexCode).toContain("app.get('/api/marketing/my-coupons'"); + expect(indexCode).toContain('getCouponsWithClaimed'); + }); + + it('下单支持"我的券"并占用,退款/取消回退', () => { + expect(repo).toContain('resolveUserCoupon(String(data.userCouponId)'); + expect(repo).toContain("WHERE id = ? AND status = 'UNUSED'"); + expect((repo.match(/releaseCouponWithConn\(/g) || []).length).toBeGreaterThanOrEqual(4); + }); + + it('小程序具备券中心页面与下单用券', () => { + expect(pagesJson).toContain('pages/user/coupons'); + expect(api).toContain('export async function claimCoupon('); + expect(api).toContain('export async function getMyCoupons('); + expect(userPage).toContain('openCoupons'); + expect(createPage).toContain('userCouponId'); + }); +});