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 次删除

查看文件

@@ -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 个;

查看文件

@@ -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 };
}

查看文件

@@ -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": {

查看文件

@@ -239,7 +239,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { createOrder, payOrder as payOrderApi, requestWechatPayment, getCompanionDetail, getUserProfile, getMarketingCoupons } from '@/common/api.js';
import { createOrder, payOrder as payOrderApi, requestWechatPayment, getCompanionDetail, getUserProfile, getMyCoupons } from '@/common/api.js';
import { preloadSubscribeTemplates, requestSubscribeFor } from '@/common/subscribe.js';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
@@ -381,21 +381,20 @@ const currentCoupon = ref(availableCoupons.value[0]);
const loadLiveCoupons = async () => {
try {
const list = await getMarketingCoupons();
if (Array.isArray(list) && list.length > 0) {
const activeCoupons = list
.filter(c => c.status === 1)
.map(c => ({
id: String(c.id),
title: c.name,
amount: Number(c.discountAmount || 0),
minAmount: Number(c.minSpend || 0),
tag: c.minSpend > 0 ? `满¥${c.minSpend}减¥${c.discountAmount}` : `立减¥${c.discountAmount}`
}));
activeCoupons.push({ id: 'c_none', title: '不使用优惠券', amount: 0, minAmount: 0, tag: '不使用' });
availableCoupons.value = activeCoupons;
autoSelectBestCoupon();
}
const uid = uni.getStorageSync('userId') || 0;
const res = uid ? await getMyCoupons(uid, 'UNUSED') : null;
const mine = Array.isArray(res?.list) ? res.list : [];
const activeCoupons = mine.map(c => ({
id: String(c.id),
title: c.name,
amount: Number(c.discountAmount || 0),
minAmount: Number(c.minSpend || 0),
categoryLimit: c.categoryLimit || 'ALL',
tag: c.minSpend > 0 ? `满¥${c.minSpend}减¥${c.discountAmount}` : `立减¥${c.discountAmount}`
}));
activeCoupons.push({ id: 'c_none', title: '不使用优惠券', amount: 0, minAmount: 0, tag: '不使用', categoryLimit: 'ALL' });
availableCoupons.value = activeCoupons;
autoSelectBestCoupon();
} catch (e) {
console.warn('[OrderCreate] 加载优惠券异常:', e);
}
@@ -474,6 +473,7 @@ const handlePay = async () => {
const uid = Number(uni.getStorageSync('userId') || currentUserId.value || 1001);
const serviceTitle = selectedService.value ? selectedService.value.title : `${expert.value.game}专属服务`;
const usingUserCoupon = currentCoupon.value && currentCoupon.value.id && currentCoupon.value.id !== 'c_none';
const basePayload = {
buyerId: uid,
clientName: currentUserName.value || '超能电竞玩家',
@@ -485,7 +485,8 @@ const handlePay = async () => {
hourlyRate: selectedService.value ? selectedService.value.price : (expert.value.price || 35),
durationHours: hours.value,
amount: finalTotal.value,
couponDiscount: discount.value,
couponDiscount: usingUserCoupon ? 0 : discount.value,
userCouponId: usingUserCoupon ? String(currentCoupon.value.id) : '',
payType: payMethod.value,
location: isOffline.value ? meetVenue.value : (remark.value || '线上语音开黑'),
expenseBurden: expenseBurden.value === 'AA' ? 'AA制各自付' : (expenseBurden.value === 'CLIENT' ? '客户全包' : '大神自理'),

查看文件

@@ -0,0 +1,400 @@
<template>
<view class="coupon-page" :class="['theme-' + currentThemeKey]" :style="navStyle">
<view class="custom-nav-bar">
<view class="nav-content">
<view class="btn-nav-back btn-active" @click="handleBack">
<image class="nav-back-icon" :src="ICONS.chevronLeft" mode="aspectFit" />
</view>
<view class="nav-title-wrap">
<text class="nav-title">优惠券</text>
</view>
<view class="nav-capsule-placeholder"></view>
</view>
</view>
<!-- 顶部切换:券中心 / 我的券 -->
<view class="tab-bar">
<view
class="tab-item btn-active"
:class="{ active: activeTab === 'center' }"
@click="switchTab('center')"
>
<text class="tab-text">券中心</text>
</view>
<view
class="tab-item btn-active"
:class="{ active: activeTab === 'mine' }"
@click="switchTab('mine')"
>
<text class="tab-text">我的券{{ usableCount > 0 ? ` (${usableCount})` : '' }}</text>
</view>
</view>
<scroll-view scroll-y class="coupon-scroll" refresher-enabled :refresher-triggered="isRefreshing" @refresherrefresh="onRefresh">
<view class="coupon-body">
<!-- 券中心 -->
<block v-if="activeTab === 'center'">
<view v-if="isLoading" class="empty-tip"><text>加载中…</text></view>
<view v-else-if="centerList.length === 0" class="empty-tip">
<text class="empty-title">暂无可领取优惠券</text>
<text>平台会不定期发放新人券与品类券,敬请关注</text>
</view>
<view v-else class="coupon-list">
<view v-for="c in centerList" :key="c.id" class="coupon-card">
<view class="coupon-amount">
<text class="amount-num">¥{{ c.discountAmount }}</text>
<text class="amount-sub">{{ c.minSpend > 0 ? `满${c.minSpend}可用` : '无门槛' }}</text>
</view>
<view class="coupon-info">
<text class="coupon-name">{{ c.name }}</text>
<text class="coupon-desc">{{ c.description || '全平台通用' }}</text>
<text class="coupon-extra">{{ c.categoryLimit && c.categoryLimit !== 'ALL' ? `限${c.categoryLimit} · ` : '全品类 · ' }}有效期 {{ c.validDays }} 天</text>
</view>
<view
class="coupon-btn btn-active"
:class="{ disabled: c.claimed || c.receivedCount >= c.totalCount }"
@click="handleClaim(c)"
>
<text>{{ c.claimed ? '已领取' : (c.receivedCount >= c.totalCount ? '已领完' : '立即领取') }}</text>
</view>
</view>
</view>
</block>
<!-- 我的券 -->
<block v-else>
<view class="filter-bar">
<text
v-for="f in statusFilters"
:key="f.key"
class="filter-item btn-active"
:class="{ active: mineFilter === f.key }"
@click="mineFilter = f.key"
>{{ f.label }}</text>
</view>
<view v-if="mineList.length === 0" class="empty-tip">
<text class="empty-title">暂无优惠券</text>
<text>去「券中心」领取平台优惠券吧</text>
</view>
<view v-else class="coupon-list">
<view
v-for="c in mineList"
:key="c.id"
class="coupon-card"
:class="{ used: c.status !== 'UNUSED' }"
>
<view class="coupon-amount">
<text class="amount-num">¥{{ c.discountAmount }}</text>
<text class="amount-sub">{{ c.minSpend > 0 ? `满${c.minSpend}可用` : '无门槛' }}</text>
</view>
<view class="coupon-info">
<text class="coupon-name">{{ c.name }}</text>
<text class="coupon-extra">{{ c.categoryLimit && c.categoryLimit !== 'ALL' ? `限${c.categoryLimit} · ` : '全品类 · ' }}有效期至 {{ formatDate(c.expiredAt) }}</text>
</view>
<view class="coupon-state">
<text>{{ c.status === 'UNUSED' ? '未使用' : (c.status === 'USED' ? '已使用' : '已过期') }}</text>
</view>
</view>
</view>
</block>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getMarketingCoupons, claimCoupon, getMyCoupons } from '@/common/api.js';
const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo());
const activeTab = ref('center');
const isLoading = ref(true);
const isRefreshing = ref(false);
const centerList = ref([]);
const mineList = ref([]);
const mineFilter = ref('UNUSED');
const statusFilters = [
{ key: 'UNUSED', label: '未使用' },
{ key: 'USED', label: '已使用' },
{ key: 'EXPIRED', label: '已过期' }
];
const navStyle = computed(() => ({
'--safe-header-gap': `${navInfo.value.totalHeaderHeight}px`,
'--capsule-right-space': `${navInfo.value.capsuleRightSpace}px`,
'--capsule-height': `${navInfo.value.capsuleHeight}px`
}));
const usableCount = computed(() => mineList.value.filter((x) => x.status === 'UNUSED').length);
const currentUserId = () => uni.getStorageSync('userId') || 0;
const formatDate = (v) => {
if (!v) return '长期有效';
const s = String(v).replace('T', ' ');
return s.slice(0, 10);
};
const loadCenter = async () => {
const list = await getMarketingCoupons();
centerList.value = Array.isArray(list) ? list : [];
};
const loadMine = async () => {
const uid = currentUserId();
if (!uid) {
mineList.value = [];
return;
}
const res = await getMyCoupons(uid);
const all = Array.isArray(res?.list) ? res.list : [];
mineList.value = all.filter((x) => x.status === mineFilter.value);
};
const reloadAll = async () => {
isLoading.value = true;
try {
await Promise.all([loadCenter(), loadMine()]);
} catch (_) {
// 忽略网络异常
} finally {
isLoading.value = false;
isRefreshing.value = false;
}
};
const switchTab = async (tab) => {
activeTab.value = tab;
await reloadAll();
};
const onRefresh = () => {
isRefreshing.value = true;
reloadAll();
};
const handleClaim = async (c) => {
if (c.claimed || c.receivedCount >= c.totalCount) return;
const uid = currentUserId();
if (!uid) {
uni.showToast({ title: '请先登录', icon: 'none' });
return;
}
try {
await claimCoupon(c.id, uid);
c.claimed = true;
c.receivedCount = Number(c.receivedCount || 0) + 1;
uni.showToast({ title: '领取成功', icon: 'success' });
await loadMine();
} catch (err) {
uni.showToast({ title: err.message || '领取失败', icon: 'none' });
}
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: '/pages/user/index' });
}
};
onShow(() => {
navInfo.value = getNavBarInfo();
currentThemeKey.value = getCurrentThemeKey();
reloadAll();
});
</script>
<style scoped>
.coupon-page {
min-height: 100vh;
background: #0a0f1d;
}
.custom-nav-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(10, 15, 29, 0.92);
padding-top: var(--safe-header-gap, 0);
}
.nav-content {
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
}
.btn-nav-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-back-icon {
width: 18px;
height: 18px;
}
.nav-title-wrap {
flex: 1;
text-align: center;
}
.nav-title {
color: #fff;
font-size: 17px;
font-weight: 600;
}
.nav-capsule-placeholder {
width: 32px;
}
.tab-bar {
display: flex;
gap: 12px;
padding: 8px 16px 0;
}
.tab-item {
flex: 1;
height: 36px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
}
.tab-item.active {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.tab-text {
color: #cbd5e1;
font-size: 13px;
font-weight: 600;
}
.tab-item.active .tab-text {
color: #fff;
}
.coupon-scroll {
height: calc(100vh - 88px - var(--safe-header-gap, 0px));
}
.coupon-body {
padding: 12px 16px 40px;
}
.filter-bar {
display: flex;
gap: 18px;
margin-bottom: 10px;
}
.filter-item {
color: #7d89a8;
font-size: 13px;
}
.filter-item.active {
color: #8b9cff;
font-weight: 600;
}
.coupon-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.coupon-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 14px;
background: linear-gradient(135deg, #1b2340 0%, #141a30 100%);
}
.coupon-card.used {
opacity: 0.55;
}
.coupon-amount {
width: 84px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.amount-num {
color: #ffd166;
font-size: 20px;
font-weight: 700;
}
.amount-sub {
color: #8d99b8;
font-size: 10px;
margin-top: 2px;
}
.coupon-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.coupon-name {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.coupon-desc {
color: #94a3b8;
font-size: 12px;
}
.coupon-extra {
color: #6d7997;
font-size: 11px;
}
.coupon-btn {
flex-shrink: 0;
padding: 7px 12px;
border-radius: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.coupon-btn text {
color: #fff;
font-size: 12px;
font-weight: 600;
}
.coupon-btn.disabled {
background: rgba(255, 255, 255, 0.12);
}
.coupon-btn.disabled text {
color: #8d99b8;
}
.coupon-state {
flex-shrink: 0;
}
.coupon-state text {
color: #8d99b8;
font-size: 12px;
}
.empty-tip {
padding: 60px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty-title {
color: #e6eaf5;
font-size: 15px;
font-weight: 600;
}
.empty-tip text {
color: #7d89a8;
font-size: 12px;
text-align: center;
line-height: 1.6;
}
.btn-active:active {
opacity: 0.75;
}
</style>

查看文件

@@ -149,6 +149,12 @@
<text class="row-label">官方在线客服</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openCoupons">
<image class="row-svg" :src="ICONS.ticket" mode="aspectFit" />
<text class="row-label">我的优惠券</text>
<text class="row-sub-tag" v-if="couponUsable > 0">{{ couponUsable }}张可用</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openNotifications">
<image class="row-svg" :src="ICONS.chat" mode="aspectFit" />
<text class="row-label">消息通知</text>
@@ -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();

查看文件

@@ -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 {

查看文件

@@ -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('小程序活动发起页面必须包含快捷时段胶囊与示范商圈及地图选点交互', () => {

查看文件

@@ -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');
});
});