feat(groupbuying): 完善拼团订单全链路及错误处理
- 拼团订单发货校验:必须成团后才能发货 - 限购校验:跨团组统计用户参与次数,超限拒绝 - 成团/失败自动通知所有成员(Redis Pub/Sub) - 拼团详情页区分"进行中"和"已成团"团组展示 - 订单类型支持三态(普通/秒杀/拼团)前后端联调 - 400错误只显示业务消息,不再重复弹出状态码 - 响应式导航栏适配及UI优化 - 新增历史数据修复SQL脚本
This commit is contained in:
@@ -85,34 +85,47 @@ service.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('响应错误:', error)
|
console.error('响应错误:', error)
|
||||||
|
|
||||||
|
// 提取后端返回的业务错误消息
|
||||||
|
const bizMessage = error.response?.data?.message
|
||||||
|
let displayMessage = ''
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
|
case 400:
|
||||||
|
// 业务错误(如"该团组已满员"),只显示后端返回的消息
|
||||||
|
displayMessage = bizMessage || '请求参数错误'
|
||||||
|
break
|
||||||
case 401:
|
case 401:
|
||||||
ElMessage.error('未授权,请登录')
|
displayMessage = '未授权,请登录'
|
||||||
break
|
break
|
||||||
case 403:
|
case 403:
|
||||||
ElMessage.error('拒绝访问')
|
displayMessage = '拒绝访问'
|
||||||
break
|
break
|
||||||
case 404:
|
case 404:
|
||||||
ElMessage.error('请求地址不存在')
|
displayMessage = '请求地址不存在'
|
||||||
break
|
break
|
||||||
case 429:
|
case 429:
|
||||||
ElMessage.error('请求过于频繁,请稍后再试')
|
displayMessage = '请求过于频繁,请稍后再试'
|
||||||
break
|
break
|
||||||
case 500:
|
case 500:
|
||||||
ElMessage.error('服务器内部错误')
|
displayMessage = '服务器内部错误'
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
ElMessage.error(error.response.data?.message || '请求失败')
|
displayMessage = bizMessage || '请求失败'
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
ElMessage.error('网络错误,请检查网络连接')
|
displayMessage = '网络错误,请检查网络连接'
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error('请求配置错误')
|
displayMessage = '请求配置错误'
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
ElMessage.error(displayMessage)
|
||||||
|
|
||||||
|
// 用业务消息替换原始 axios error message,避免组件 catch 显示 "Request failed with status code 400"
|
||||||
|
const rejectError = new Error(displayMessage)
|
||||||
|
;(rejectError as any)._handled = true
|
||||||
|
return Promise.reject(rejectError)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ const reviewedItems = computed(() => items.value.filter(i => i.reviewed))
|
|||||||
const canSubmit = computed(() => reviewableItems.value.some(i => i.content.trim()))
|
const canSubmit = computed(() => reviewableItems.value.some(i => i.content.trim()))
|
||||||
|
|
||||||
const loadReviewStatus = async () => {
|
const loadReviewStatus = async () => {
|
||||||
if (!props.orderId || !props.orderItems.length) return
|
if (!props.orderId || !props.orderItems.length) {
|
||||||
|
items.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
checkLoading.value = true
|
checkLoading.value = true
|
||||||
try {
|
try {
|
||||||
const list: ReviewableItem[] = props.orderItems.map(item => ({
|
const list: ReviewableItem[] = props.orderItems.map(item => ({
|
||||||
@@ -177,5 +180,14 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
watch(() => props.visible, (val) => {
|
||||||
if (val) loadReviewStatus()
|
if (val) loadReviewStatus()
|
||||||
|
if (!val) items.value = []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.orderId, props.orderItems],
|
||||||
|
() => {
|
||||||
|
if (props.visible) loadReviewStatus()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<nav class="flex items-center justify-between h-16">
|
<nav class="header-nav">
|
||||||
<!-- Logo -->
|
<div class="header-brand">
|
||||||
<div class="flex items-center">
|
|
||||||
<router-link to="/" class="brand-link">
|
<router-link to="/" class="brand-link">
|
||||||
<el-icon :size="24" class="brand-icon">
|
<el-icon :size="24" class="brand-icon">
|
||||||
<Lightning />
|
<Lightning />
|
||||||
@@ -14,9 +13,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 导航菜单 -->
|
<div class="header-links hidden xl:flex items-center">
|
||||||
<div class="hidden md:flex items-center space-x-8">
|
|
||||||
<router-link to="/" class="nav-link">
|
<router-link to="/" class="nav-link">
|
||||||
<el-icon><HomeFilled /></el-icon>
|
<el-icon><HomeFilled /></el-icon>
|
||||||
首页
|
首页
|
||||||
@@ -49,22 +47,35 @@
|
|||||||
拼团
|
拼团
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧菜单 -->
|
<div class="header-actions">
|
||||||
<div class="flex items-center space-x-4">
|
<el-dropdown class="header-menu xl:hidden" trigger="click" @command="handleMainNavCommand">
|
||||||
<SearchComponent />
|
<button type="button" class="menu-trigger" aria-label="打开导航菜单">
|
||||||
|
<el-icon :size="18"><Menu /></el-icon>
|
||||||
<!-- 通知中心 -->
|
<span class="hidden sm:inline">菜单</span>
|
||||||
|
</button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="/">首页</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="/flashsales">秒杀活动</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="/products">商品列表</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="/groupbuying">拼团</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
|
<div class="header-search hidden lg:block">
|
||||||
|
<SearchComponent />
|
||||||
|
</div>
|
||||||
|
|
||||||
<NotificationCenter v-if="userStore.isLoggedIn" />
|
<NotificationCenter v-if="userStore.isLoggedIn" />
|
||||||
|
|
||||||
<!-- 购物车 -->
|
|
||||||
<router-link to="/cart" class="cart-link relative">
|
<router-link to="/cart" class="cart-link relative">
|
||||||
<el-badge :value="cartCount" :hidden="cartCount === 0" class="cart-badge">
|
<el-badge :value="cartCount" :hidden="cartCount === 0" class="cart-badge">
|
||||||
<el-icon :size="20"><ShoppingCart /></el-icon>
|
<el-icon :size="20"><ShoppingCart /></el-icon>
|
||||||
</el-badge>
|
</el-badge>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- 用户菜单 -->
|
|
||||||
<template v-if="userStore.isLoggedIn">
|
<template v-if="userStore.isLoggedIn">
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<div class="user-trigger flex items-center space-x-2 cursor-pointer">
|
<div class="user-trigger flex items-center space-x-2 cursor-pointer">
|
||||||
@@ -107,8 +118,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 未登录 -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button text @click="router.push('/login')">登录</el-button>
|
<el-button text @click="router.push('/login')">登录</el-button>
|
||||||
<el-button type="primary" @click="router.push('/register')">注册</el-button>
|
<el-button type="primary" @click="router.push('/register')">注册</el-button>
|
||||||
@@ -157,6 +167,12 @@ const handleCategoryCommand = (category: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMainNavCommand = (path: string) => {
|
||||||
|
if (path) {
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 退出登录
|
// 退出登录
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||||
@@ -193,11 +209,43 @@ onMounted(() => {
|
|||||||
border-bottom: 1px solid #d8cebf;
|
border-bottom: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
min-height: var(--app-header-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand,
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-links {
|
||||||
|
gap: 28px;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-link {
|
.brand-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: #171715;
|
color: #171715;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-icon {
|
.brand-icon {
|
||||||
@@ -213,9 +261,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-tag {
|
.brand-tag {
|
||||||
@@ -227,6 +276,7 @@ onMounted(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@@ -234,6 +284,7 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px 2px;
|
padding: 8px 2px;
|
||||||
|
font-size: 14px;
|
||||||
color: #5e5e58;
|
color: #5e5e58;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.25s ease;
|
transition: color 0.25s ease;
|
||||||
@@ -267,7 +318,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cart-link,
|
.menu-trigger,
|
||||||
.user-trigger {
|
.user-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -278,6 +329,32 @@ onMounted(() => {
|
|||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
color: #2b2b27;
|
color: #2b2b27;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-trigger {
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 6px;
|
||||||
|
color: #2b2b27;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
:deep(.search-input) {
|
||||||
|
width: clamp(180px, 18vw, 280px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cart-badge {
|
.cart-badge {
|
||||||
@@ -287,4 +364,31 @@ onMounted(() => {
|
|||||||
border: 1px solid #171715;
|
border: 1px solid #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.brand-tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.header-nav {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-link,
|
||||||
|
.user-trigger,
|
||||||
|
.menu-trigger {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -286,11 +286,13 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
border: 1px solid #d8cebf;
|
padding: 6px;
|
||||||
border-radius: 999px;
|
border: 1px solid transparent;
|
||||||
background: #fffaf2;
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #2b2b27;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import AppFooter from '@/components/common/AppFooter.vue'
|
|||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-top: 60px; // header高度
|
padding-top: var(--app-header-height);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
<el-option label="已发货" value="3" />
|
<el-option label="已发货" value="3" />
|
||||||
<el-option label="已完成" value="4" />
|
<el-option label="已完成" value="4" />
|
||||||
<el-option label="已取消" value="5" />
|
<el-option label="已取消" value="5" />
|
||||||
|
<el-option label="退货中" value="6" />
|
||||||
|
<el-option label="已退货" value="7" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
@@ -63,7 +65,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="类型" width="100">
|
<el-table-column label="类型" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.isFlashSale ? 'danger' : 'info'">{{ row.isFlashSale ? '秒杀订单' : '普通订单' }}</el-tag>
|
<el-tag :type="getOrderTypeTag(row.orderType)">{{ getOrderTypeText(row.orderType) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
@@ -182,6 +184,8 @@ const getStatusText = (status: string) => {
|
|||||||
SHIPPED: '已发货',
|
SHIPPED: '已发货',
|
||||||
COMPLETED: '已完成',
|
COMPLETED: '已完成',
|
||||||
CANCELLED: '已取消',
|
CANCELLED: '已取消',
|
||||||
|
REFUNDING: '退货中',
|
||||||
|
REFUNDED: '已退货',
|
||||||
}
|
}
|
||||||
return map[status] || status
|
return map[status] || status
|
||||||
}
|
}
|
||||||
@@ -193,10 +197,22 @@ const getStatusType = (status: string) => {
|
|||||||
SHIPPED: 'success',
|
SHIPPED: 'success',
|
||||||
COMPLETED: 'success',
|
COMPLETED: 'success',
|
||||||
CANCELLED: 'info',
|
CANCELLED: 'info',
|
||||||
|
REFUNDING: 'warning',
|
||||||
|
REFUNDED: 'danger',
|
||||||
}
|
}
|
||||||
return map[status] || 'info'
|
return map[status] || 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOrderTypeText = (orderType: string) => {
|
||||||
|
const map: Record<string, string> = { NORMAL: '普通订单', FLASH_SALE: '秒杀订单', GROUP_BUYING: '拼团订单' }
|
||||||
|
return map[orderType] || '普通订单'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOrderTypeTag = (orderType: string) => {
|
||||||
|
const map: Record<string, string> = { NORMAL: 'info', FLASH_SALE: 'danger', GROUP_BUYING: 'success' }
|
||||||
|
return map[orderType] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
const res = await adminApi.getOrderStats()
|
const res = await adminApi.getOrderStats()
|
||||||
Object.assign(stats, res.data)
|
Object.assign(stats, res.data)
|
||||||
|
|||||||
@@ -81,18 +81,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 进行中的团组 -->
|
<!-- 进行中的团组 -->
|
||||||
<div class="groups-section">
|
<div class="groups-section mb-8">
|
||||||
<h2 class="text-xl font-bold mb-4">
|
<h2 class="text-xl font-bold mb-4">
|
||||||
进行中的团组
|
进行中的团组
|
||||||
<span class="text-sm text-gray-400 ml-2">({{ groups.length }} 个)</span>
|
<span class="text-sm text-gray-400 ml-2">({{ formingGroups.length }} 个)</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="groups.length === 0" class="text-center py-10">
|
<div v-if="formingGroups.length === 0" class="text-center py-10">
|
||||||
<el-empty description="暂无进行中的团组,快来开团吧!" />
|
<el-empty description="暂无进行中的团组,快来开团吧!" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div v-for="group in groups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
|
<div v-for="group in formingGroups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
|
||||||
style="background: #fffaf2; border: 1px solid #e8e0d4">
|
style="background: #fffaf2; border: 1px solid #e8e0d4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
||||||
@@ -117,6 +117,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 已成团的团组 -->
|
||||||
|
<div v-if="completedGroups.length > 0" class="groups-section">
|
||||||
|
<h2 class="text-xl font-bold mb-4">
|
||||||
|
已成团
|
||||||
|
<span class="text-sm text-gray-400 ml-2">({{ completedGroups.length }} 个)</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-for="group in completedGroups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
|
||||||
|
style="background: #f8f8f6; border: 1px solid #e8e0d4; opacity: 0.85">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
|
||||||
|
<div class="text-sm text-green-600">
|
||||||
|
<el-icon class="mr-1"><CircleCheckFilled /></el-icon>
|
||||||
|
已成团 · {{ group.currentMembers }}/{{ group.requiredMembers }} 人
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex -space-x-2">
|
||||||
|
<el-avatar v-for="m in group.members.slice(0, 5)" :key="m.userId" :size="28" :src="m.avatar">
|
||||||
|
{{ m.username ? m.username[0] : '?' }}
|
||||||
|
</el-avatar>
|
||||||
|
</div>
|
||||||
|
<el-tag type="success" size="small">已成团</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +158,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Loading } from '@element-plus/icons-vue'
|
import { Loading, CircleCheckFilled } from '@element-plus/icons-vue'
|
||||||
import type { GroupBuying, GroupBuyingGroup } from '@/types/api'
|
import type { GroupBuying, GroupBuyingGroup } from '@/types/api'
|
||||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
@@ -141,6 +173,9 @@ const groups = ref<GroupBuyingGroup[]>([])
|
|||||||
|
|
||||||
const id = computed(() => Number(route.params.id))
|
const id = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
const formingGroups = computed(() => groups.value.filter(g => g.status === 'FORMING'))
|
||||||
|
const completedGroups = computed(() => groups.value.filter(g => g.status === 'SUCCESS'))
|
||||||
|
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
switch (detail.value?.status) {
|
switch (detail.value?.status) {
|
||||||
case 'UPCOMING': return 'warning'
|
case 'UPCOMING': return 'warning'
|
||||||
@@ -175,7 +210,7 @@ const loadDetail = async () => {
|
|||||||
const loadGroups = async () => {
|
const loadGroups = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await groupbuyingApi.getGroups(id.value, { page: 0, size: 50 })
|
const res = await groupbuyingApi.getGroups(id.value, { page: 0, size: 50 })
|
||||||
groups.value = res.data.content.filter(g => g.status === 'FORMING')
|
groups.value = res.data.content
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载团组列表失败', e)
|
console.error('加载团组列表失败', e)
|
||||||
}
|
}
|
||||||
@@ -187,8 +222,8 @@ const handleCreateGroup = async () => {
|
|||||||
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value })
|
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value })
|
||||||
ElMessage.success(res.data.message || '开团成功')
|
ElMessage.success(res.data.message || '开团成功')
|
||||||
router.push(`/groupbuying/group/${res.data.groupId}`)
|
router.push(`/groupbuying/group/${res.data.groupId}`)
|
||||||
} catch (e: any) {
|
} catch {
|
||||||
ElMessage.error(e.message || '开团失败')
|
// 全局拦截器已处理错误提示
|
||||||
} finally {
|
} finally {
|
||||||
joining.value = false
|
joining.value = false
|
||||||
}
|
}
|
||||||
@@ -200,8 +235,8 @@ const handleJoinGroup = async (groupId: number) => {
|
|||||||
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value, groupId })
|
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value, groupId })
|
||||||
ElMessage.success(res.data.message || '加入成功')
|
ElMessage.success(res.data.message || '加入成功')
|
||||||
router.push(`/groupbuying/group/${res.data.groupId}`)
|
router.push(`/groupbuying/group/${res.data.groupId}`)
|
||||||
} catch (e: any) {
|
} catch {
|
||||||
ElMessage.error(e.message || '加入失败')
|
// 全局拦截器已处理错误提示
|
||||||
} finally {
|
} finally {
|
||||||
joining.value = false
|
joining.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ const handleJoin = async () => {
|
|||||||
})
|
})
|
||||||
ElMessage.success(res.data.message || '加入成功')
|
ElMessage.success(res.data.message || '加入成功')
|
||||||
await loadGroup()
|
await loadGroup()
|
||||||
} catch (e: any) {
|
} catch {
|
||||||
ElMessage.error(e.message || '加入失败')
|
// 全局拦截器已处理错误提示
|
||||||
} finally {
|
} finally {
|
||||||
joining.value = false
|
joining.value = false
|
||||||
}
|
}
|
||||||
@@ -146,9 +146,10 @@ const handleCancel = async () => {
|
|||||||
ElMessage.success('已退出团组')
|
ElMessage.success('已退出团组')
|
||||||
await loadGroup()
|
await loadGroup()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e !== 'cancel') {
|
if (e !== 'cancel' && !(e instanceof Error)) {
|
||||||
ElMessage.error(e.message || '退出失败')
|
// ElMessageBox 取消操作,忽略
|
||||||
}
|
}
|
||||||
|
// API 错误由全局拦截器处理
|
||||||
} finally {
|
} finally {
|
||||||
cancelling.value = false
|
cancelling.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,11 @@
|
|||||||
<span>订单号:{{ order.orderNo }}</span>
|
<span>订单号:{{ order.orderNo }}</span>
|
||||||
<span>{{ formatTime(order.createdAt) }}</span>
|
<span>{{ formatTime(order.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
|
<div class="flex items-center gap-2">
|
||||||
|
<el-tag v-if="order.orderType === 'FLASH_SALE'" type="danger" size="small">秒杀</el-tag>
|
||||||
|
<el-tag v-else-if="order.orderType === 'GROUP_BUYING'" type="success" size="small">拼团</el-tag>
|
||||||
|
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--app-header-height: 72px;
|
||||||
--tone-0: #fffdf8;
|
--tone-0: #fffdf8;
|
||||||
--tone-50: #f7f2ea;
|
--tone-50: #f7f2ea;
|
||||||
--tone-100: #efe7dc;
|
--tone-100: #efe7dc;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface AdminRecentOrderRow {
|
|||||||
quantity: number
|
quantity: number
|
||||||
totalAmount: number
|
totalAmount: number
|
||||||
status: string
|
status: string
|
||||||
|
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
|
||||||
createdAt: string
|
createdAt: string
|
||||||
isFlashSale: boolean
|
isFlashSale: boolean
|
||||||
}
|
}
|
||||||
@@ -81,6 +82,7 @@ export interface AdminOrderRow {
|
|||||||
quantity: number
|
quantity: number
|
||||||
totalAmount: number
|
totalAmount: number
|
||||||
status: string
|
status: string
|
||||||
|
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
|
||||||
createdAt: string
|
createdAt: string
|
||||||
isFlashSale: boolean
|
isFlashSale: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
1
flash-sale-frontend/src/types/api.d.ts
vendored
1
flash-sale-frontend/src/types/api.d.ts
vendored
@@ -113,6 +113,7 @@ export interface Order {
|
|||||||
paymentAmount: number
|
paymentAmount: number
|
||||||
paymentMethod?: string
|
paymentMethod?: string
|
||||||
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED'
|
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED'
|
||||||
|
orderType?: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
|
||||||
items: OrderItem[]
|
items: OrderItem[]
|
||||||
address?: OrderAddress
|
address?: OrderAddress
|
||||||
remark?: string
|
remark?: string
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ export const mapOrderStatus = (status: number | string): Order['status'] => {
|
|||||||
return 'PENDING'
|
return 'PENDING'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const mapOrderType = (orderType: number | string | undefined, isFlashSale?: boolean): 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING' => {
|
||||||
|
const value = typeof orderType === 'string' ? orderType : toNumber(orderType)
|
||||||
|
if (value === 'FLASH_SALE' || value === 2) return 'FLASH_SALE'
|
||||||
|
if (value === 'GROUP_BUYING' || value === 3) return 'GROUP_BUYING'
|
||||||
|
if (isFlashSale) return 'FLASH_SALE'
|
||||||
|
return 'NORMAL'
|
||||||
|
}
|
||||||
|
|
||||||
export const mapFlashSaleStatus = (status: number | string): FlashSale['status'] => {
|
export const mapFlashSaleStatus = (status: number | string): FlashSale['status'] => {
|
||||||
const value = typeof status === 'string' ? status : toNumber(status)
|
const value = typeof status === 'string' ? status : toNumber(status)
|
||||||
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
|
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
|
||||||
@@ -186,6 +194,7 @@ export const normalizeOrder = (order: Record<string, any>): Order => {
|
|||||||
paymentAmount: totalAmount,
|
paymentAmount: totalAmount,
|
||||||
paymentMethod: toString(order.paymentMethod) || (status === 'PENDING' ? undefined : 'ONLINE'),
|
paymentMethod: toString(order.paymentMethod) || (status === 'PENDING' ? undefined : 'ONLINE'),
|
||||||
status,
|
status,
|
||||||
|
orderType: mapOrderType(order.orderType, order.isFlashSale),
|
||||||
items,
|
items,
|
||||||
address: buildOrderAddress(order),
|
address: buildOrderAddress(order),
|
||||||
remark: toString(order.remark),
|
remark: toString(order.remark),
|
||||||
@@ -237,6 +246,7 @@ export const normalizeAdminRecentOrder = (order: Record<string, any>): AdminRece
|
|||||||
quantity: toNumber(order.quantity, 1),
|
quantity: toNumber(order.quantity, 1),
|
||||||
totalAmount: toNumber(order.totalAmount ?? order.totalPrice),
|
totalAmount: toNumber(order.totalAmount ?? order.totalPrice),
|
||||||
status: mapOrderStatus(order.status),
|
status: mapOrderStatus(order.status),
|
||||||
|
orderType: mapOrderType(order.orderType, order.isFlashSale),
|
||||||
createdAt: toIsoLikeString(order.createdAt),
|
createdAt: toIsoLikeString(order.createdAt),
|
||||||
isFlashSale: Boolean(order.isFlashSale),
|
isFlashSale: Boolean(order.isFlashSale),
|
||||||
})
|
})
|
||||||
@@ -271,6 +281,7 @@ export const normalizeAdminOrder = (order: Record<string, any>): AdminOrderRow =
|
|||||||
quantity: toNumber(order.quantity, 1),
|
quantity: toNumber(order.quantity, 1),
|
||||||
totalAmount: toNumber(order.totalAmount),
|
totalAmount: toNumber(order.totalAmount),
|
||||||
status: mapOrderStatus(order.status),
|
status: mapOrderStatus(order.status),
|
||||||
|
orderType: mapOrderType(order.orderType, order.isFlashSale),
|
||||||
createdAt: toIsoLikeString(order.createdAt),
|
createdAt: toIsoLikeString(order.createdAt),
|
||||||
isFlashSale: Boolean(order.isFlashSale),
|
isFlashSale: Boolean(order.isFlashSale),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -57,10 +57,16 @@ public class ProductReviewController {
|
|||||||
|
|
||||||
@GetMapping("/check")
|
@GetMapping("/check")
|
||||||
public ResponseEntity<Map<String, Object>> checkReview(@RequestParam Long orderId,
|
public ResponseEntity<Map<String, Object>> checkReview(@RequestParam Long orderId,
|
||||||
@RequestParam Long productId) {
|
@RequestParam Long productId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("success", true);
|
response.put("success", true);
|
||||||
response.put("data", productReviewService.checkReviewStatus(orderId, productId));
|
response.put("data", productReviewService.checkReviewStatus(userId, orderId, productId));
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,9 +170,12 @@ public class OrderDTO {
|
|||||||
private Long shippedOrders;
|
private Long shippedOrders;
|
||||||
private Long completedOrders;
|
private Long completedOrders;
|
||||||
private Long cancelledOrders;
|
private Long cancelledOrders;
|
||||||
|
private Long refundingOrders;
|
||||||
|
private Long refundedOrders;
|
||||||
private BigDecimal totalAmount;
|
private BigDecimal totalAmount;
|
||||||
private BigDecimal todayAmount;
|
private BigDecimal todayAmount;
|
||||||
private Long flashSaleOrders;
|
private Long flashSaleOrders;
|
||||||
private Long normalOrders;
|
private Long normalOrders;
|
||||||
|
private Long groupBuyingOrders;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ public interface GroupBuyingMemberRepository extends JpaRepository<GroupBuyingMe
|
|||||||
@Modifying
|
@Modifying
|
||||||
@Query("UPDATE GroupBuyingMember m SET m.status = :status WHERE m.groupId = :groupId AND m.status = 1")
|
@Query("UPDATE GroupBuyingMember m SET m.status = :status WHERE m.groupId = :groupId AND m.status = 1")
|
||||||
int updateStatusByGroupId(@Param("groupId") Long groupId, @Param("status") Integer status);
|
int updateStatusByGroupId(@Param("groupId") Long groupId, @Param("status") Integer status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户在某个拼团活动的所有团组中的有效参与次数(排除已取消 status=3)
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(m) FROM GroupBuyingMember m WHERE m.userId = :userId AND m.status != 3 " +
|
||||||
|
"AND m.groupId IN (SELECT g.id FROM GroupBuyingGroup g WHERE g.groupBuyingId = :activityId)")
|
||||||
|
long countActiveByUserIdAndActivityId(@Param("userId") Long userId, @Param("activityId") Long activityId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ public class AdminService {
|
|||||||
orderMap.put("totalAmount", order.getTotalPrice());
|
orderMap.put("totalAmount", order.getTotalPrice());
|
||||||
orderMap.put("status", order.getStatus());
|
orderMap.put("status", order.getStatus());
|
||||||
orderMap.put("createdAt", order.getCreatedAt());
|
orderMap.put("createdAt", order.getCreatedAt());
|
||||||
|
orderMap.put("orderType", order.getOrderType());
|
||||||
orderMap.put("isFlashSale", order.getOrderType() == 2); // 2表示秒杀订单
|
orderMap.put("isFlashSale", order.getOrderType() == 2); // 2表示秒杀订单
|
||||||
return orderMap;
|
return orderMap;
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
@@ -429,6 +430,7 @@ public class AdminService {
|
|||||||
orderMap.put("totalAmount", order.getTotalPrice());
|
orderMap.put("totalAmount", order.getTotalPrice());
|
||||||
orderMap.put("status", order.getStatus());
|
orderMap.put("status", order.getStatus());
|
||||||
orderMap.put("createdAt", order.getCreatedAt());
|
orderMap.put("createdAt", order.getCreatedAt());
|
||||||
|
orderMap.put("orderType", order.getOrderType());
|
||||||
orderMap.put("isFlashSale", order.getOrderType() == 2); // 2表示秒杀订单
|
orderMap.put("isFlashSale", order.getOrderType() == 2); // 2表示秒杀订单
|
||||||
return orderMap;
|
return orderMap;
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ public class GroupBuyingService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private OrderItemRepository orderItemRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private RedisService redisService;
|
private RedisService redisService;
|
||||||
|
|
||||||
@@ -293,6 +296,12 @@ public class GroupBuyingService {
|
|||||||
throw new RuntimeException("库存不足");
|
throw new RuntimeException("库存不足");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1.1 校验限购:用户在该活动下的有效参与次数
|
||||||
|
long userJoinCount = groupBuyingMemberRepository.countActiveByUserIdAndActivityId(userId, activityId);
|
||||||
|
if (userJoinCount >= gb.getMaxPerUser()) {
|
||||||
|
throw new RuntimeException("您已达到该活动的限购数量(" + gb.getMaxPerUser() + " 件)");
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 获取商品信息
|
// 2. 获取商品信息
|
||||||
Product product = productRepository.findById(gb.getProductId())
|
Product product = productRepository.findById(gb.getProductId())
|
||||||
.orElseThrow(() -> new RuntimeException("商品不存在"));
|
.orElseThrow(() -> new RuntimeException("商品不存在"));
|
||||||
@@ -365,6 +374,17 @@ public class GroupBuyingService {
|
|||||||
order.setGroupBuyingGroupId(group.getId());
|
order.setGroupBuyingGroupId(group.getId());
|
||||||
order = orderRepository.save(order);
|
order = orderRepository.save(order);
|
||||||
|
|
||||||
|
// 5.1 创建订单明细
|
||||||
|
OrderItem orderItem = new OrderItem();
|
||||||
|
orderItem.setOrderId(order.getId());
|
||||||
|
orderItem.setProductId(product.getId());
|
||||||
|
orderItem.setProductName(product.getName());
|
||||||
|
orderItem.setProductImageUrl(product.getImageUrl());
|
||||||
|
orderItem.setPrice(gb.getGroupPrice());
|
||||||
|
orderItem.setQuantity(1);
|
||||||
|
orderItem.setSubtotal(gb.getGroupPrice());
|
||||||
|
orderItemRepository.save(orderItem);
|
||||||
|
|
||||||
// 6. 创建成员记录
|
// 6. 创建成员记录
|
||||||
GroupBuyingMember member = new GroupBuyingMember();
|
GroupBuyingMember member = new GroupBuyingMember();
|
||||||
member.setGroupId(group.getId());
|
member.setGroupId(group.getId());
|
||||||
@@ -385,6 +405,10 @@ public class GroupBuyingService {
|
|||||||
// Update all members status to SUCCESS
|
// Update all members status to SUCCESS
|
||||||
groupBuyingMemberRepository.updateStatusByGroupId(group.getId(), 2);
|
groupBuyingMemberRepository.updateStatusByGroupId(group.getId(), 2);
|
||||||
log.info("拼团成功: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
|
log.info("拼团成功: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
|
||||||
|
|
||||||
|
// 发布拼团成功通知给所有成员
|
||||||
|
publishGroupStatusChange(group.getId(), group.getGroupNo(), activityId,
|
||||||
|
gb.getProductId(), product.getName(), "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to Redis members set
|
// Add to Redis members set
|
||||||
@@ -502,6 +526,14 @@ public class GroupBuyingService {
|
|||||||
// Clean Redis
|
// Clean Redis
|
||||||
redisService.delete(GB_MEMBERS_PREFIX + group.getId());
|
redisService.delete(GB_MEMBERS_PREFIX + group.getId());
|
||||||
|
|
||||||
|
// 发布拼团失败通知
|
||||||
|
GroupBuying gb = groupBuyingRepository.findById(group.getGroupBuyingId()).orElse(null);
|
||||||
|
if (gb != null) {
|
||||||
|
Product product = productRepository.findById(gb.getProductId()).orElse(null);
|
||||||
|
publishGroupStatusChange(group.getId(), group.getGroupNo(), group.getGroupBuyingId(),
|
||||||
|
gb.getProductId(), product != null ? product.getName() : "未知商品", "failed");
|
||||||
|
}
|
||||||
|
|
||||||
log.info("超时团组处理完成: groupId={}", group.getId());
|
log.info("超时团组处理完成: groupId={}", group.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,4 +681,28 @@ public class GroupBuyingService {
|
|||||||
default: return "未知";
|
default: return "未知";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布拼团状态变更消息(通知所有成员)
|
||||||
|
*/
|
||||||
|
private void publishGroupStatusChange(Long groupId, String groupNo, Long activityId,
|
||||||
|
Long productId, String productName, String action) {
|
||||||
|
List<GroupBuyingMember> members = groupBuyingMemberRepository.findByGroupId(groupId);
|
||||||
|
List<Long> memberUserIds = members.stream()
|
||||||
|
.map(GroupBuyingMember::getUserId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<String, Object> message = new HashMap<>();
|
||||||
|
message.put("groupId", groupId);
|
||||||
|
message.put("groupNo", groupNo);
|
||||||
|
message.put("activityId", activityId);
|
||||||
|
message.put("productId", productId);
|
||||||
|
message.put("productName", productName);
|
||||||
|
message.put("action", action);
|
||||||
|
message.put("memberUserIds", memberUserIds);
|
||||||
|
message.put("timestamp", System.currentTimeMillis());
|
||||||
|
|
||||||
|
redisService.publish("groupbuying:status:change", message);
|
||||||
|
log.info("发布拼团状态变更消息: groupId={}, action={}, members={}", groupId, action, memberUserIds.size());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +66,12 @@ public class MessageListenerService {
|
|||||||
new ChannelTopic("return:status:change")
|
new ChannelTopic("return:status:change")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 拼团状态变更监听
|
||||||
|
redisMessageListenerContainer.addMessageListener(
|
||||||
|
new GroupBuyingStatusChangeListener(),
|
||||||
|
new ChannelTopic("groupbuying:status:change")
|
||||||
|
);
|
||||||
|
|
||||||
log.info("Redis消息监听器初始化完成");
|
log.info("Redis消息监听器初始化完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +239,48 @@ public class MessageListenerService {
|
|||||||
log.info("退货状态变更通知已创建: 订单ID={}, 操作={}", orderId, action);
|
log.info("退货状态变更通知已创建: 订单ID={}, 操作={}", orderId, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理拼团状态变更
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void handleGroupBuyingStatusChange(String action, Map<String, Object> data) {
|
||||||
|
String groupNo = data.get("groupNo") != null ? data.get("groupNo").toString() : "";
|
||||||
|
String productName = data.get("productName") != null ? data.get("productName").toString() : "商品";
|
||||||
|
Long activityId = extractLongValue(data.get("activityId"));
|
||||||
|
|
||||||
|
List<Long> memberUserIds = new ArrayList<>();
|
||||||
|
Object memberObj = data.get("memberUserIds");
|
||||||
|
if (memberObj instanceof List) {
|
||||||
|
for (Object item : (List<?>) memberObj) {
|
||||||
|
Long uid = extractLongValue(item);
|
||||||
|
if (uid != null) memberUserIds.add(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String title;
|
||||||
|
String message;
|
||||||
|
String link = "/groupbuying/" + (activityId != null ? activityId : "");
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "success":
|
||||||
|
title = "拼团成功";
|
||||||
|
message = "您参与的「" + productName + "」拼团已成功!请尽快完成支付。";
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
title = "拼团失败";
|
||||||
|
message = "很遗憾,您参与的「" + productName + "」拼团未能成团,订单已自动取消。";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.info("未知拼团状态变更: {}", action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Long userId : memberUserIds) {
|
||||||
|
notificationService.createNotification(userId, "groupbuying", title, message, link);
|
||||||
|
}
|
||||||
|
log.info("拼团状态变更通知已创建: groupNo={}, action={}, 通知人数={}", groupNo, action, memberUserIds.size());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查库存预警
|
* 检查库存预警
|
||||||
*/
|
*/
|
||||||
@@ -417,4 +467,27 @@ public class MessageListenerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼团状态变更监听器
|
||||||
|
*/
|
||||||
|
private class GroupBuyingStatusChangeListener implements MessageListener {
|
||||||
|
@Override
|
||||||
|
public void onMessage(Message message, byte[] pattern) {
|
||||||
|
try {
|
||||||
|
String messageBody = new String(message.getBody());
|
||||||
|
log.debug("接收到拼团状态变更消息: {}", messageBody);
|
||||||
|
|
||||||
|
Map<String, Object> data = parseRedissonMessage(messageBody);
|
||||||
|
String action = data.get("action").toString();
|
||||||
|
|
||||||
|
log.info("拼团状态变更: action={}", action);
|
||||||
|
|
||||||
|
handleGroupBuyingStatusChange(action, data);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理拼团状态变更消息失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.org.flashsalesystem.service;
|
|||||||
import com.org.flashsalesystem.dto.OrderDTO;
|
import com.org.flashsalesystem.dto.OrderDTO;
|
||||||
import com.org.flashsalesystem.dto.ProductDTO;
|
import com.org.flashsalesystem.dto.ProductDTO;
|
||||||
import com.org.flashsalesystem.dto.UserDTO;
|
import com.org.flashsalesystem.dto.UserDTO;
|
||||||
|
import com.org.flashsalesystem.entity.GroupBuyingGroup;
|
||||||
import com.org.flashsalesystem.entity.Order;
|
import com.org.flashsalesystem.entity.Order;
|
||||||
import com.org.flashsalesystem.entity.OrderItem;
|
import com.org.flashsalesystem.entity.OrderItem;
|
||||||
import com.org.flashsalesystem.entity.UserAddress;
|
import com.org.flashsalesystem.entity.UserAddress;
|
||||||
|
import com.org.flashsalesystem.repository.GroupBuyingGroupRepository;
|
||||||
import com.org.flashsalesystem.repository.OrderItemRepository;
|
import com.org.flashsalesystem.repository.OrderItemRepository;
|
||||||
import com.org.flashsalesystem.repository.OrderRepository;
|
import com.org.flashsalesystem.repository.OrderRepository;
|
||||||
import com.org.flashsalesystem.repository.ProductRepository;
|
import com.org.flashsalesystem.repository.ProductRepository;
|
||||||
@@ -56,6 +58,8 @@ public class OrderService {
|
|||||||
private FlashSaleService flashSaleService;
|
private FlashSaleService flashSaleService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private GroupBuyingService groupBuyingService;
|
private GroupBuyingService groupBuyingService;
|
||||||
|
@Autowired
|
||||||
|
private GroupBuyingGroupRepository groupBuyingGroupRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建普通订单
|
* 创建普通订单
|
||||||
@@ -321,6 +325,17 @@ public class OrderService {
|
|||||||
throw new RuntimeException("无效的状态转换");
|
throw new RuntimeException("无效的状态转换");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拼团订单发货校验:必须成团后才能发货
|
||||||
|
if (newStatus == 3 && order.getOrderType() != null && order.getOrderType() == 3) {
|
||||||
|
if (order.getGroupBuyingGroupId() == null) {
|
||||||
|
throw new RuntimeException("拼团订单数据异常,缺少团组信息");
|
||||||
|
}
|
||||||
|
GroupBuyingGroup group = groupBuyingGroupRepository.findById(order.getGroupBuyingGroupId()).orElse(null);
|
||||||
|
if (group == null || group.getStatus() != 2) {
|
||||||
|
throw new RuntimeException("拼团尚未成团,不能发货");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新状态
|
// 更新状态
|
||||||
order.setStatus(newStatus);
|
order.setStatus(newStatus);
|
||||||
if (remark != null && !remark.trim().isEmpty()) {
|
if (remark != null && !remark.trim().isEmpty()) {
|
||||||
@@ -371,6 +386,20 @@ public class OrderService {
|
|||||||
throw new RuntimeException("订单状态不正确,无法支付");
|
throw new RuntimeException("订单状态不正确,无法支付");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拼团订单校验:团组必须存在且未过期/未失败
|
||||||
|
if (order.getOrderType() != null && order.getOrderType() == 3 && order.getGroupBuyingGroupId() != null) {
|
||||||
|
GroupBuyingGroup group = groupBuyingGroupRepository.findById(order.getGroupBuyingGroupId()).orElse(null);
|
||||||
|
if (group == null) {
|
||||||
|
throw new RuntimeException("拼团团组不存在");
|
||||||
|
}
|
||||||
|
if (group.getStatus() == 3) {
|
||||||
|
throw new RuntimeException("拼团已失败,无法支付");
|
||||||
|
}
|
||||||
|
if (group.getStatus() == 1 && LocalDateTime.now().isAfter(group.getExpireTime())) {
|
||||||
|
throw new RuntimeException("拼团已过期,无法支付");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
order.setStatus(2);
|
order.setStatus(2);
|
||||||
order.setPaymentMethod(paymentMethod);
|
order.setPaymentMethod(paymentMethod);
|
||||||
order.setPaidAt(LocalDateTime.now());
|
order.setPaidAt(LocalDateTime.now());
|
||||||
@@ -532,10 +561,13 @@ public class OrderService {
|
|||||||
statistics.setShippedOrders(orderRepository.countByStatus(3));
|
statistics.setShippedOrders(orderRepository.countByStatus(3));
|
||||||
statistics.setCompletedOrders(orderRepository.countByStatus(4));
|
statistics.setCompletedOrders(orderRepository.countByStatus(4));
|
||||||
statistics.setCancelledOrders(orderRepository.countByStatus(5));
|
statistics.setCancelledOrders(orderRepository.countByStatus(5));
|
||||||
|
statistics.setRefundingOrders(orderRepository.countByStatus(6));
|
||||||
|
statistics.setRefundedOrders(orderRepository.countByStatus(7));
|
||||||
|
|
||||||
// 订单类型统计
|
// 订单类型统计
|
||||||
statistics.setNormalOrders(orderRepository.countByOrderType(1));
|
statistics.setNormalOrders(orderRepository.countByOrderType(1));
|
||||||
statistics.setFlashSaleOrders(orderRepository.countByOrderType(2));
|
statistics.setFlashSaleOrders(orderRepository.countByOrderType(2));
|
||||||
|
statistics.setGroupBuyingOrders(orderRepository.countByOrderType(3));
|
||||||
|
|
||||||
// 金额统计(这里简化处理,实际应该从数据库聚合查询)
|
// 金额统计(这里简化处理,实际应该从数据库聚合查询)
|
||||||
List<Order> allOrders = orderRepository.findAll();
|
List<Order> allOrders = orderRepository.findAll();
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ public class ProductReviewService {
|
|||||||
return toDTO(review);
|
return toDTO(review);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProductReviewDTO.CheckDTO checkReviewStatus(Long orderId, Long productId) {
|
public ProductReviewDTO.CheckDTO checkReviewStatus(Long userId, Long orderId, Long productId) {
|
||||||
ProductReviewDTO.CheckDTO checkDTO = new ProductReviewDTO.CheckDTO();
|
ProductReviewDTO.CheckDTO checkDTO = new ProductReviewDTO.CheckDTO();
|
||||||
Optional<ProductReview> review = productReviewRepository.findByOrderIdAndProductId(orderId, productId);
|
Optional<ProductReview> review = productReviewRepository.findByOrderIdAndUserIdAndProductId(orderId, userId, productId);
|
||||||
checkDTO.setReviewed(review.isPresent());
|
checkDTO.setReviewed(review.isPresent());
|
||||||
review.ifPresent(r -> checkDTO.setReview(toDTO(r)));
|
review.ifPresent(r -> checkDTO.setReview(toDTO(r)));
|
||||||
return checkDTO;
|
return checkDTO;
|
||||||
|
|||||||
132
src/main/resources/sql/fix-groupbuying-data.sql
Normal file
132
src/main/resources/sql/fix-groupbuying-data.sql
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 拼团历史数据修复脚本
|
||||||
|
-- 修复内容:
|
||||||
|
-- 1. 已满员但状态仍为 FORMING(1) 的团组 → 更新为 SUCCESS(2)
|
||||||
|
-- 2. 已成团团组的成员状态从 已加入(1) → 已成团(2)
|
||||||
|
-- 3. 拼团订单的 order_type 修正为 3(拼团订单)
|
||||||
|
-- 4. 已过期且未满员的 FORMING 团组 → 更新为 FAILED(3)
|
||||||
|
-- 5. 已失败团组的成员状态更新
|
||||||
|
-- 使用方式:mysql -u root -p flash_sale_db < fix-groupbuying-data.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 先查看当前数据状况(仅查询,不修改)
|
||||||
|
SELECT '====== 修复前数据概况 ======' AS info;
|
||||||
|
|
||||||
|
SELECT '满员但仍为FORMING的团组' AS category, COUNT(*) AS cnt
|
||||||
|
FROM group_buying_group
|
||||||
|
WHERE status = 1 AND current_members >= required_members;
|
||||||
|
|
||||||
|
SELECT '已过期但仍为FORMING的团组' AS category, COUNT(*) AS cnt
|
||||||
|
FROM group_buying_group
|
||||||
|
WHERE status = 1 AND expire_time < NOW();
|
||||||
|
|
||||||
|
SELECT '拼团订单但order_type不是3的订单' AS category, COUNT(*) AS cnt
|
||||||
|
FROM orders
|
||||||
|
WHERE group_buying_group_id IS NOT NULL AND order_type != 3;
|
||||||
|
|
||||||
|
SELECT '缺少order_items记录的拼团订单' AS category, COUNT(*) AS cnt
|
||||||
|
FROM orders o
|
||||||
|
WHERE o.group_buying_group_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 修复1: 已满员的团组 → SUCCESS(2)
|
||||||
|
-- ============================================================
|
||||||
|
UPDATE group_buying_group
|
||||||
|
SET status = 2,
|
||||||
|
completed_at = COALESCE(completed_at, NOW())
|
||||||
|
WHERE status = 1
|
||||||
|
AND current_members >= required_members;
|
||||||
|
|
||||||
|
SELECT CONCAT('修复1完成: ', ROW_COUNT(), ' 个满员团组已更新为已成团') AS result;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 修复2: 已成团(status=2)团组的成员状态 → 已成团(2)
|
||||||
|
-- ============================================================
|
||||||
|
UPDATE group_buying_member m
|
||||||
|
INNER JOIN group_buying_group g ON m.group_id = g.id
|
||||||
|
SET m.status = 2
|
||||||
|
WHERE g.status = 2
|
||||||
|
AND m.status = 1;
|
||||||
|
|
||||||
|
SELECT CONCAT('修复2完成: ', ROW_COUNT(), ' 个成员状态已更新为已成团') AS result;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 修复3: 已过期且未满员的团组 → FAILED(3)
|
||||||
|
-- ============================================================
|
||||||
|
UPDATE group_buying_group
|
||||||
|
SET status = 3
|
||||||
|
WHERE status = 1
|
||||||
|
AND expire_time < NOW()
|
||||||
|
AND current_members < required_members;
|
||||||
|
|
||||||
|
SELECT CONCAT('修复3完成: ', ROW_COUNT(), ' 个过期团组已更新为已失败') AS result;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 修复4: 已失败(status=3)团组的成员状态 → 已退出(3)
|
||||||
|
-- ============================================================
|
||||||
|
UPDATE group_buying_member m
|
||||||
|
INNER JOIN group_buying_group g ON m.group_id = g.id
|
||||||
|
SET m.status = 3
|
||||||
|
WHERE g.status = 3
|
||||||
|
AND m.status = 1;
|
||||||
|
|
||||||
|
SELECT CONCAT('修复4完成: ', ROW_COUNT(), ' 个失败团组成员已更新为已退出') AS result;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 修复5: 拼团关联订单的 order_type 修正为 3
|
||||||
|
-- ============================================================
|
||||||
|
UPDATE orders
|
||||||
|
SET order_type = 3
|
||||||
|
WHERE group_buying_group_id IS NOT NULL
|
||||||
|
AND order_type != 3;
|
||||||
|
|
||||||
|
SELECT CONCAT('修复5完成: ', ROW_COUNT(), ' 个拼团订单的order_type已修正') AS result;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 修复6: 补充缺失的 order_items 记录(拼团订单)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO order_items (order_id, product_id, product_name, product_image_url, price, quantity, subtotal, created_at)
|
||||||
|
SELECT o.id,
|
||||||
|
o.product_id,
|
||||||
|
COALESCE(p.name, '未知商品'),
|
||||||
|
p.image_url,
|
||||||
|
o.total_price / o.quantity,
|
||||||
|
o.quantity,
|
||||||
|
o.total_price,
|
||||||
|
o.created_at
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN products p ON o.product_id = p.id
|
||||||
|
WHERE o.group_buying_group_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id);
|
||||||
|
|
||||||
|
SELECT CONCAT('修复6完成: ', ROW_COUNT(), ' 条缺失的订单明细已补充') AS result;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 修复7: 已失败团组关联的待支付订单 → 取消(5)
|
||||||
|
-- ============================================================
|
||||||
|
UPDATE orders o
|
||||||
|
INNER JOIN group_buying_group g ON o.group_buying_group_id = g.id
|
||||||
|
SET o.status = 5
|
||||||
|
WHERE g.status = 3
|
||||||
|
AND o.status = 1;
|
||||||
|
|
||||||
|
SELECT CONCAT('修复7完成: ', ROW_COUNT(), ' 个失败团组的待支付订单已取消') AS result;
|
||||||
|
|
||||||
|
-- 修复后数据验证
|
||||||
|
SELECT '====== 修复后数据验证 ======' AS info;
|
||||||
|
|
||||||
|
SELECT status,
|
||||||
|
CASE status WHEN 1 THEN '拼团中' WHEN 2 THEN '已成团' WHEN 3 THEN '已失败' END AS status_desc,
|
||||||
|
COUNT(*) AS cnt
|
||||||
|
FROM group_buying_group
|
||||||
|
GROUP BY status
|
||||||
|
ORDER BY status;
|
||||||
|
|
||||||
|
SELECT '仍有异常的团组(满员但非SUCCESS)' AS category, COUNT(*) AS cnt
|
||||||
|
FROM group_buying_group
|
||||||
|
WHERE current_members >= required_members AND status != 2;
|
||||||
|
|
||||||
|
SELECT '仍有异常的团组(过期但仍FORMING)' AS category, COUNT(*) AS cnt
|
||||||
|
FROM group_buying_group
|
||||||
|
WHERE expire_time < NOW() AND status = 1;
|
||||||
Reference in New Issue
Block a user