From c4582655d975706e6ad7d40e6c8aab2a2e9e74a4 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 14 Mar 2026 16:40:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4JSP=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E5=B1=82=EF=BC=8C=E5=AE=8C=E5=96=84=E8=AF=84=E4=BB=B7=E5=92=8C?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8B=BC=E5=9B=A2=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除所有 JSP 页面(20个文件),前端完全迁移至 Vue 3 SPA - 完善评价系统:ReviewDialog 组件、用户评价历史页、评价状态检查API - 新增通知系统:Notification 实体/仓库/服务/控制器,NotificationCenter 接入真实API - 新增拼团模块:GroupBuying 全套后端和前端页面 - 修复 review check API 参数双重包装导致请求格式错误 - 修复通知 API 路径缺少 /api 前缀和响应格式处理 - MessageListenerService 集成 NotificationService 创建持久化通知 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 +- README.md | 15 +- flash-sale-frontend/.env.development | 2 +- flash-sale-frontend/package-lock.json | 64 + flash-sale-frontend/package.json | 10 +- flash-sale-frontend/src/App.vue | 4 +- .../src/api/modules/flashsale.ts | 5 + .../src/api/modules/groupbuying.ts | 108 ++ .../src/api/modules/notification.ts | 45 + flash-sale-frontend/src/api/modules/review.ts | 20 + flash-sale-frontend/src/api/product.ts | 44 - flash-sale-frontend/src/api/request.ts | 3 +- .../src/components/business/CountDown.vue | 16 +- .../src/components/business/FlashSaleCard.vue | 19 +- .../components/business/GroupBuyingCard.vue | 118 ++ .../components/business/GroupMemberList.vue | 51 + .../src/components/business/ProductCard.vue | 10 +- .../src/components/business/ReviewDialog.vue | 181 +++ .../src/components/common/AppFooter.vue | 33 +- .../src/components/common/AppHeader.vue | 158 +- .../components/common/NotificationCenter.vue | 211 ++- .../src/components/common/SafeImage.vue | 6 +- .../src/components/common/SearchComponent.vue | 24 +- .../src/layouts/AdminLayout.vue | 55 +- .../src/layouts/MainLayout.vue | 4 +- .../src/pages/admin/dashboard.vue | 34 +- .../src/pages/admin/favorites.vue | 8 +- .../src/pages/admin/flashsales.vue | 76 +- .../src/pages/admin/groupbuying.vue | 485 ++++++ .../src/pages/admin/monitor.vue | 33 +- .../src/pages/admin/orders.vue | 21 +- .../src/pages/admin/products.vue | 24 +- .../src/pages/admin/reviews.vue | 8 +- flash-sale-frontend/src/pages/admin/users.vue | 15 +- flash-sale-frontend/src/pages/cart/index.vue | 2 +- .../src/pages/flashsale/detail.vue | 46 +- .../src/pages/flashsale/index.vue | 63 +- .../src/pages/groupbuying/detail.vue | 219 +++ .../src/pages/groupbuying/group.vue | 167 ++ .../src/pages/groupbuying/index.vue | 148 ++ flash-sale-frontend/src/pages/home/index.vue | 131 +- .../src/pages/order/detail.vue | 38 +- flash-sale-frontend/src/pages/order/index.vue | 55 +- .../src/pages/product/detail.vue | 70 +- .../src/pages/product/index.vue | 84 +- .../src/pages/user/favorites.vue | 2 +- flash-sale-frontend/src/pages/user/login.vue | 53 +- .../src/pages/user/notifications.vue | 184 +++ .../src/pages/user/profile.vue | 13 +- .../src/pages/user/register.vue | 19 +- .../src/pages/user/reviews.vue | 122 ++ flash-sale-frontend/src/router/index.ts | 36 + flash-sale-frontend/src/stores/user.ts | 26 +- flash-sale-frontend/src/styles/index.scss | 486 +++++- flash-sale-frontend/src/types/api.d.ts | 61 + flash-sale-frontend/src/utils/normalizers.ts | 68 + flash-sale-frontend/tailwind.config.js | 22 +- flash-sale-frontend/vite.config.ts | 7 + .../FlashSaleSystemApplication.java | 2 + .../config/RedissonConfig.java | 12 + .../controller/FlashSaleController.java | 25 + .../controller/GroupBuyingController.java | 277 ++++ .../controller/NotificationController.java | 116 ++ .../controller/ProductReviewController.java | 47 +- .../flashsalesystem/dto/GroupBuyingDTO.java | 173 +++ .../com/org/flashsalesystem/dto/OrderDTO.java | 1 + .../flashsalesystem/dto/ProductReviewDTO.java | 10 + .../flashsalesystem/entity/GroupBuying.java | 98 ++ .../entity/GroupBuyingGroup.java | 80 + .../entity/GroupBuyingMember.java | 53 + .../flashsalesystem/entity/Notification.java | 49 + .../com/org/flashsalesystem/entity/Order.java | 9 +- .../repository/FlashSaleRepository.java | 20 +- .../GroupBuyingGroupRepository.java | 47 + .../GroupBuyingMemberRepository.java | 29 + .../repository/GroupBuyingRepository.java | 45 + .../repository/NotificationRepository.java | 30 + .../repository/OrderRepository.java | 27 +- .../repository/ProductReviewRepository.java | 10 + .../flashsalesystem/service/CartService.java | 3 + .../service/FlashSaleService.java | 130 +- .../service/GroupBuyingService.java | 652 ++++++++ .../service/MessageListenerService.java | 77 +- .../service/NotificationService.java | 57 + .../flashsalesystem/service/OrderService.java | 38 +- .../service/ProductReviewService.java | 35 +- .../service/ProductService.java | 4 +- src/main/resources/application.yml | 2 + src/main/resources/lua/groupbuying_stock.lua | 32 + src/main/resources/sql/demo-users.sql | 33 +- src/main/resources/sql/fix-demo-users.sql | 33 - src/main/resources/sql/schema.sql | 313 ++-- src/main/resources/sql/test-data.sql | 229 ++- src/main/resources/sql/update-passwords.sql | 42 - .../webapp/WEB-INF/views/admin/flashsales.jsp | 1211 --------------- src/main/webapp/WEB-INF/views/admin/index.jsp | 464 ------ .../webapp/WEB-INF/views/admin/monitor.jsp | 515 ------- .../webapp/WEB-INF/views/admin/orders.jsp | 545 ------- .../webapp/WEB-INF/views/admin/products.jsp | 1341 ----------------- src/main/webapp/WEB-INF/views/admin/users.jsp | 404 ----- src/main/webapp/WEB-INF/views/cart.jsp | 658 -------- .../webapp/WEB-INF/views/common/footer.jsp | 215 --- .../webapp/WEB-INF/views/common/header.jsp | 288 ---- src/main/webapp/WEB-INF/views/error.jsp | 83 - .../webapp/WEB-INF/views/flashsale-detail.jsp | 873 ----------- src/main/webapp/WEB-INF/views/flashsales.jsp | 915 ----------- src/main/webapp/WEB-INF/views/index.jsp | 703 --------- src/main/webapp/WEB-INF/views/login.jsp | 248 --- .../webapp/WEB-INF/views/order-detail.jsp | 571 ------- src/main/webapp/WEB-INF/views/orders.jsp | 861 ----------- .../webapp/WEB-INF/views/product-detail.jsp | 383 ----- src/main/webapp/WEB-INF/views/products.jsp | 505 ------- src/main/webapp/WEB-INF/views/profile.jsp | 493 ------ src/main/webapp/WEB-INF/views/register.jsp | 383 ----- .../service/FlashSaleServiceTest.java | 54 +- 115 files changed, 5968 insertions(+), 12623 deletions(-) create mode 100644 flash-sale-frontend/src/api/modules/groupbuying.ts create mode 100644 flash-sale-frontend/src/api/modules/notification.ts delete mode 100644 flash-sale-frontend/src/api/product.ts create mode 100644 flash-sale-frontend/src/components/business/GroupBuyingCard.vue create mode 100644 flash-sale-frontend/src/components/business/GroupMemberList.vue create mode 100644 flash-sale-frontend/src/components/business/ReviewDialog.vue create mode 100644 flash-sale-frontend/src/pages/admin/groupbuying.vue create mode 100644 flash-sale-frontend/src/pages/groupbuying/detail.vue create mode 100644 flash-sale-frontend/src/pages/groupbuying/group.vue create mode 100644 flash-sale-frontend/src/pages/groupbuying/index.vue create mode 100644 flash-sale-frontend/src/pages/user/notifications.vue create mode 100644 flash-sale-frontend/src/pages/user/reviews.vue create mode 100644 src/main/java/com/org/flashsalesystem/controller/GroupBuyingController.java create mode 100644 src/main/java/com/org/flashsalesystem/controller/NotificationController.java create mode 100644 src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java create mode 100644 src/main/java/com/org/flashsalesystem/entity/GroupBuying.java create mode 100644 src/main/java/com/org/flashsalesystem/entity/GroupBuyingGroup.java create mode 100644 src/main/java/com/org/flashsalesystem/entity/GroupBuyingMember.java create mode 100644 src/main/java/com/org/flashsalesystem/entity/Notification.java create mode 100644 src/main/java/com/org/flashsalesystem/repository/GroupBuyingGroupRepository.java create mode 100644 src/main/java/com/org/flashsalesystem/repository/GroupBuyingMemberRepository.java create mode 100644 src/main/java/com/org/flashsalesystem/repository/GroupBuyingRepository.java create mode 100644 src/main/java/com/org/flashsalesystem/repository/NotificationRepository.java create mode 100644 src/main/java/com/org/flashsalesystem/service/GroupBuyingService.java create mode 100644 src/main/java/com/org/flashsalesystem/service/NotificationService.java create mode 100644 src/main/resources/lua/groupbuying_stock.lua delete mode 100644 src/main/resources/sql/fix-demo-users.sql delete mode 100644 src/main/resources/sql/update-passwords.sql delete mode 100644 src/main/webapp/WEB-INF/views/admin/flashsales.jsp delete mode 100644 src/main/webapp/WEB-INF/views/admin/index.jsp delete mode 100644 src/main/webapp/WEB-INF/views/admin/monitor.jsp delete mode 100644 src/main/webapp/WEB-INF/views/admin/orders.jsp delete mode 100644 src/main/webapp/WEB-INF/views/admin/products.jsp delete mode 100644 src/main/webapp/WEB-INF/views/admin/users.jsp delete mode 100644 src/main/webapp/WEB-INF/views/cart.jsp delete mode 100644 src/main/webapp/WEB-INF/views/common/footer.jsp delete mode 100644 src/main/webapp/WEB-INF/views/common/header.jsp delete mode 100644 src/main/webapp/WEB-INF/views/error.jsp delete mode 100644 src/main/webapp/WEB-INF/views/flashsale-detail.jsp delete mode 100644 src/main/webapp/WEB-INF/views/flashsales.jsp delete mode 100644 src/main/webapp/WEB-INF/views/index.jsp delete mode 100644 src/main/webapp/WEB-INF/views/login.jsp delete mode 100644 src/main/webapp/WEB-INF/views/order-detail.jsp delete mode 100644 src/main/webapp/WEB-INF/views/orders.jsp delete mode 100644 src/main/webapp/WEB-INF/views/product-detail.jsp delete mode 100644 src/main/webapp/WEB-INF/views/products.jsp delete mode 100644 src/main/webapp/WEB-INF/views/profile.jsp delete mode 100644 src/main/webapp/WEB-INF/views/register.jsp diff --git a/CLAUDE.md b/CLAUDE.md index d8e9b2e..9bb0277 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,11 +66,11 @@ mysql -u root -p -e "CREATE DATABASE flash_sale_db CHARACTER SET utf8mb4 COLLATE # 导入表结构(JPA自动创建,可选) mysql -u root -p flash_sale_db < src/main/resources/sql/schema.sql -# 导入测试数据 -mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql - # 导入演示用户 mysql -u root -p flash_sale_db < src/main/resources/sql/demo-users.sql + +# 导入测试数据 +mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql ``` ## Redis架构设计 diff --git a/README.md b/README.md index 5d66a54..918453c 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,9 @@ FlashSaleSystem/ │ │ ├── rate_limit.lua # 限流脚本 │ │ └── unlock.lua # 解锁脚本 │ ├── sql/ # SQL脚本 -│ │ ├── demo-users.sql # 演示用户数据 -│ │ ├── fix-demo-users.sql # 修复用户数据 -│ │ ├── schema.sql # 数据库架构 -│ │ ├── test-data.sql # 测试数据 -│ │ └── update-passwords.sql # 更新密码 +│ │ ├── demo-users.sql # 演示账号 +│ │ ├── schema.sql # 数据库结构 +│ │ └── test-data.sql # 测试业务数据 │ └── static/images/ # 静态图片资源 └── src/main/webapp/WEB-INF/views/ # JSP页面 ├── admin/ # 管理员页面 @@ -228,13 +226,14 @@ cd FlashSaleSystem ```bash # 创建数据库 mysql -u root -p -CREATE DATABASE flashsale_db; +CREATE DATABASE flash_sale_db; # 导入数据库架构 -mysql -u root -p flashsale_db < src/main/resources/sql/schema.sql +mysql -u root -p flash_sale_db < src/main/resources/sql/schema.sql # 导入测试数据 -mysql -u root -p flashsale_db < src/main/resources/sql/test-data.sql +mysql -u root -p flash_sale_db < src/main/resources/sql/demo-users.sql +mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql ``` 3. **配置 Redis 集群** diff --git a/flash-sale-frontend/.env.development b/flash-sale-frontend/.env.development index e61b69e..37baa74 100644 --- a/flash-sale-frontend/.env.development +++ b/flash-sale-frontend/.env.development @@ -1,6 +1,6 @@ # 开发环境配置 VITE_APP_TITLE=秒杀系统 -VITE_API_BASE_URL=http://localhost:8080 +VITE_API_BASE_URL= VITE_WS_URL=ws://localhost:8080/ws VITE_UPLOAD_URL=http://localhost:8080/upload VITE_TIMEOUT=10000 diff --git a/flash-sale-frontend/package-lock.json b/flash-sale-frontend/package-lock.json index a59a75b..4ad8e25 100644 --- a/flash-sale-frontend/package-lock.json +++ b/flash-sale-frontend/package-lock.json @@ -25,6 +25,7 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/node": "^20.11.5", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", @@ -1142,6 +1143,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.7", @@ -4221,6 +4238,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", diff --git a/flash-sale-frontend/package.json b/flash-sale-frontend/package.json index 2bbe813..598d260 100644 --- a/flash-sale-frontend/package.json +++ b/flash-sale-frontend/package.json @@ -8,7 +8,10 @@ "build": "vue-tsc && vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", - "format": "prettier --write src/" + "format": "prettier --write src/", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "vue": "^3.4.15", @@ -43,6 +46,7 @@ "@typescript-eslint/parser": "^6.19.0", "prettier": "^3.2.4", "@vue/eslint-config-prettier": "^9.0.0", - "@vue/eslint-config-typescript": "^12.0.0" + "@vue/eslint-config-typescript": "^12.0.0", + "@playwright/test": "^1.52.0" } -} \ No newline at end of file +} diff --git a/flash-sale-frontend/src/App.vue b/flash-sale-frontend/src/App.vue index 6f67843..c6c4c5b 100644 --- a/flash-sale-frontend/src/App.vue +++ b/flash-sale-frontend/src/App.vue @@ -21,6 +21,6 @@ onMounted(() => { \ No newline at end of file + diff --git a/flash-sale-frontend/src/api/modules/flashsale.ts b/flash-sale-frontend/src/api/modules/flashsale.ts index e0b6070..fa35e96 100644 --- a/flash-sale-frontend/src/api/modules/flashsale.ts +++ b/flash-sale-frontend/src/api/modules/flashsale.ts @@ -16,6 +16,11 @@ const flashSaleSortField = (sort?: string) => { } export const flashsaleApi = { + // 获取秒杀活动统计信息(即将开始/正在进行/我的参与/抢购成功) + getStatistics(): Promise> { + return request.get('/api/flashsale/statistics') + }, + // 获取秒杀活动列表 getList(params?: PageParams & { status?: string }): Promise>> { return request.post>>('/api/flashsale/list', { diff --git a/flash-sale-frontend/src/api/modules/groupbuying.ts b/flash-sale-frontend/src/api/modules/groupbuying.ts new file mode 100644 index 0000000..bb05d34 --- /dev/null +++ b/flash-sale-frontend/src/api/modules/groupbuying.ts @@ -0,0 +1,108 @@ +import { request } from '../request' +import type { ApiResponse, GroupBuying, GroupBuyingGroup, GroupBuyingStatistics, PageParams, PageResponse } from '@/types/api' +import { normalizeGroupBuying, normalizeGroupBuyingGroup, normalizePage } from '@/utils/normalizers' + +const groupBuyingStatusToCode = (status?: string) => { + if (status === 'DRAFT') return 0 + if (status === 'UPCOMING') return 1 + if (status === 'ACTIVE') return 2 + if (status === 'ENDED') return 3 + return undefined +} + +export const groupbuyingApi = { + getStatistics(): Promise> { + return request.get('/api/groupbuying/statistics') + }, + + getList(params?: PageParams & { status?: string }): Promise>> { + return request.get>>('/api/groupbuying/list', { + status: groupBuyingStatusToCode(params?.status), + page: params?.page ?? 0, + size: params?.size ?? 10, + }).then((res) => ({ + ...res, + data: normalizePage(res.data, normalizeGroupBuying), + })) + }, + + getDetail(id: number): Promise> { + return request.get>(`/api/groupbuying/${id}`).then((res) => ({ + ...res, + data: normalizeGroupBuying(res.data), + })) + }, + + getGroups(id: number, params?: PageParams): Promise>> { + return request.get>>(`/api/groupbuying/${id}/groups`, { + page: params?.page ?? 0, + size: params?.size ?? 10, + }).then((res) => ({ + ...res, + data: normalizePage(res.data, normalizeGroupBuyingGroup), + })) + }, + + joinGroup(data: { groupBuyingId: number; groupId?: number }): Promise> { + return request.post('/api/groupbuying/join', data) + }, + + getGroupDetail(groupId: number): Promise> { + return request.get>(`/api/groupbuying/group/${groupId}`).then((res) => ({ + ...res, + data: normalizeGroupBuyingGroup(res.data), + })) + }, + + cancelMembership(groupId: number): Promise { + return request.post(`/api/groupbuying/group/${groupId}/cancel`) + }, + + getMyGroups(params?: PageParams): Promise>> { + return request.get>>('/api/groupbuying/my-groups', { + page: params?.page ?? 0, + size: params?.size ?? 10, + }).then((res) => ({ + ...res, + data: normalizePage(res.data, normalizeGroupBuyingGroup), + })) + }, + + // Admin + create(data: { + productId: number + groupPrice: number + requiredMembers: number + durationMinutes: number + totalStock: number + maxPerUser: number + startTime: string + endTime: string + }): Promise> { + return request.post>('/api/groupbuying/admin/create', data).then((res) => ({ + ...res, + data: normalizeGroupBuying(res.data), + })) + }, + + update(id: number, data: Record): Promise> { + return request.put>(`/api/groupbuying/admin/${id}`, data).then((res) => ({ + ...res, + data: normalizeGroupBuying(res.data), + })) + }, + + delete(id: number): Promise { + return request.delete(`/api/groupbuying/admin/${id}`) + }, + + preloadAll(): Promise { + return request.post('/api/groupbuying/admin/preload-all') + }, +} diff --git a/flash-sale-frontend/src/api/modules/notification.ts b/flash-sale-frontend/src/api/modules/notification.ts new file mode 100644 index 0000000..606a5eb --- /dev/null +++ b/flash-sale-frontend/src/api/modules/notification.ts @@ -0,0 +1,45 @@ +import { request } from '../request' + +export interface NotificationItem { + id: number + userId: number + type: 'flashsale' | 'order' | 'system' + title: string + message: string + link?: string + read: boolean + createdAt: string +} + +interface ApiRes { + success: boolean + message?: string + data: T +} + +export const notificationApi = { + /** 获取通知列表 */ + getList(type?: string): Promise> { + return request.get('/api/notification/list', type ? { type } : undefined) + }, + + /** 获取未读数量 */ + getUnreadCount(): Promise> { + return request.get('/api/notification/unread-count') + }, + + /** 标记单条已读 */ + markAsRead(id: number): Promise { + return request.put(`/api/notification/${id}/read`) + }, + + /** 全部标记已读 */ + markAllAsRead(): Promise { + return request.put('/api/notification/read-all') + }, + + /** 清空所有通知 */ + clearAll(): Promise { + return request.delete('/api/notification/clear') + } +} diff --git a/flash-sale-frontend/src/api/modules/review.ts b/flash-sale-frontend/src/api/modules/review.ts index 1484c22..dfa7dbe 100644 --- a/flash-sale-frontend/src/api/modules/review.ts +++ b/flash-sale-frontend/src/api/modules/review.ts @@ -7,8 +7,11 @@ export interface ReviewItem { userId: number orderId: number username: string + productName?: string + productImage?: string rating: number content: string + adminReply?: string createdAt: string updatedAt?: string } @@ -19,6 +22,11 @@ export interface ReviewSummary { reviews: ReviewItem[] } +export interface ReviewCheckResult { + reviewed: boolean + review?: ReviewItem +} + export const reviewApi = { getProductReviews(productId: number): Promise> { return request.get(`/api/review/product/${productId}`) @@ -27,4 +35,16 @@ export const reviewApi = { create(data: { orderId: number; productId: number; rating: number; content: string }): Promise> { return request.post('/api/review', data) }, + + checkReview(orderId: number, productId: number): Promise> { + return request.get('/api/review/check', { orderId, productId }) + }, + + getMyReviews(): Promise> { + return request.get('/api/review/my') + }, + + getOrderReviews(orderId: number): Promise> { + return request.get(`/api/review/order/${orderId}`) + }, } diff --git a/flash-sale-frontend/src/api/product.ts b/flash-sale-frontend/src/api/product.ts deleted file mode 100644 index d44e753..0000000 --- a/flash-sale-frontend/src/api/product.ts +++ /dev/null @@ -1,44 +0,0 @@ -import request from './request' -import type { Product, ProductParams } from '@/types/product' - -export const productApi = { - // 获取商品列表 - getList(params?: ProductParams) { - return request.get('/api/products', { params }) - }, - - // 获取商品详情 - getDetail(id: number) { - return request.get(`/api/products/${id}`) - }, - - // 获取热门商品 - getHot(limit: number = 8) { - return request.get('/api/products/hot', { - params: { limit } - }) - }, - - // 获取推荐商品 - getRecommended(limit: number = 8) { - return request.get('/api/products/recommended', { - params: { limit } - }) - }, - - // 搜索商品 - search(keyword: string) { - return request.get('/api/products/search', { - params: { keyword } - }) - }, - - // 按分类获取商品 - getByCategory(categoryId: number) { - return request.get('/api/products/category', { - params: { categoryId } - }) - } -} - -export default productApi \ No newline at end of file diff --git a/flash-sale-frontend/src/api/request.ts b/flash-sale-frontend/src/api/request.ts index ce021f6..b398e67 100644 --- a/flash-sale-frontend/src/api/request.ts +++ b/flash-sale-frontend/src/api/request.ts @@ -10,7 +10,8 @@ import router from '@/router' // 创建axios实例 const service: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL, + baseURL: import.meta.env.VITE_API_BASE_URL || '', + withCredentials: true, timeout: Number(import.meta.env.VITE_TIMEOUT) || 10000, headers: { 'Content-Type': 'application/json', diff --git a/flash-sale-frontend/src/components/business/CountDown.vue b/flash-sale-frontend/src/components/business/CountDown.vue index 20f2ef0..598da63 100644 --- a/flash-sale-frontend/src/components/business/CountDown.vue +++ b/flash-sale-frontend/src/components/business/CountDown.vue @@ -1,7 +1,7 @@ \ No newline at end of file + diff --git a/flash-sale-frontend/src/pages/product/index.vue b/flash-sale-frontend/src/pages/product/index.vue index a218412..ae23aa7 100644 --- a/flash-sale-frontend/src/pages/product/index.vue +++ b/flash-sale-frontend/src/pages/product/index.vue @@ -4,28 +4,50 @@

- + 商品列表

精选好物,品质保证

+ +
+
+ + 全部 + + + {{ cat }} + +
+
+
- - @@ -111,7 +133,7 @@ \ No newline at end of file + +.page-icon { + color: #44443f; +} + +.category-tag { + font-size: 14px; + padding: 6px 16px; + border-radius: 999px; + transition: all 0.2s; + + &:hover { + transform: translateY(-1px); + } +} + diff --git a/flash-sale-frontend/src/pages/user/favorites.vue b/flash-sale-frontend/src/pages/user/favorites.vue index bd6b56f..09a2b04 100644 --- a/flash-sale-frontend/src/pages/user/favorites.vue +++ b/flash-sale-frontend/src/pages/user/favorites.vue @@ -94,7 +94,7 @@ onMounted(() => { diff --git a/flash-sale-frontend/src/pages/user/notifications.vue b/flash-sale-frontend/src/pages/user/notifications.vue new file mode 100644 index 0000000..d76e30c --- /dev/null +++ b/flash-sale-frontend/src/pages/user/notifications.vue @@ -0,0 +1,184 @@ + + + diff --git a/flash-sale-frontend/src/pages/user/profile.vue b/flash-sale-frontend/src/pages/user/profile.vue index 299d3bf..4e5f8ee 100644 --- a/flash-sale-frontend/src/pages/user/profile.vue +++ b/flash-sale-frontend/src/pages/user/profile.vue @@ -326,16 +326,15 @@ onMounted(async () => { \ No newline at end of file + +.page-mark { + color: #171715; +} + +.register-panel { + border: 1px solid #d8cebf; + border-radius: 24px; + background: #fffaf2; + box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06); +} + diff --git a/flash-sale-frontend/src/pages/user/reviews.vue b/flash-sale-frontend/src/pages/user/reviews.vue new file mode 100644 index 0000000..c32a61f --- /dev/null +++ b/flash-sale-frontend/src/pages/user/reviews.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/flash-sale-frontend/src/router/index.ts b/flash-sale-frontend/src/router/index.ts index 8caf1f2..31538e9 100644 --- a/flash-sale-frontend/src/router/index.ts +++ b/flash-sale-frontend/src/router/index.ts @@ -80,6 +80,36 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/user/favorites.vue'), meta: { title: '我的收藏', requiresAuth: true } }, + { + path: 'reviews', + name: 'MyReviews', + component: () => import('@/pages/user/reviews.vue'), + meta: { title: '我的评价', requiresAuth: true } + }, + { + path: 'notifications', + name: 'Notifications', + component: () => import('@/pages/user/notifications.vue'), + meta: { title: '消息通知', requiresAuth: true } + }, + { + path: 'groupbuying', + name: 'GroupBuying', + component: () => import('@/pages/groupbuying/index.vue'), + meta: { title: '拼团活动' } + }, + { + path: 'groupbuying/:id', + name: 'GroupBuyingDetail', + component: () => import('@/pages/groupbuying/detail.vue'), + meta: { title: '拼团详情' } + }, + { + path: 'groupbuying/group/:id', + name: 'GroupBuyingGroupDetail', + component: () => import('@/pages/groupbuying/group.vue'), + meta: { title: '团组详情', requiresAuth: true } + }, { path: 'addresses', name: 'Addresses', @@ -123,6 +153,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/admin/flashsales.vue'), meta: { title: '秒杀管理' } }, + { + path: 'groupbuying', + name: 'AdminGroupBuying', + component: () => import('@/pages/admin/groupbuying.vue'), + meta: { title: '拼团管理' } + }, { path: 'orders', name: 'AdminOrders', diff --git a/flash-sale-frontend/src/stores/user.ts b/flash-sale-frontend/src/stores/user.ts index b2f23d6..ce3c1dd 100644 --- a/flash-sale-frontend/src/stores/user.ts +++ b/flash-sale-frontend/src/stores/user.ts @@ -34,16 +34,32 @@ export const useUserStore = defineStore('user', () => { if (res.success) { token.value = res.data.token user.value = res.data.user - - // 保存到localStorage + localStorage.setItem('token', token.value) localStorage.setItem('user', JSON.stringify(user.value)) + + try { + const profile = await userApi.getInfo() + if (profile.success) { + user.value = { + ...profile.data, + avatar: profile.data.avatar || user.value?.avatar || '', + } + localStorage.setItem('user', JSON.stringify(user.value)) + } + } catch (sessionError) { + console.error('登录成功但会话校验失败:', sessionError) + user.value = null + token.value = '' + localStorage.removeItem('token') + localStorage.removeItem('user') + ElMessage.error('登录成功但会话未建立,请检查 Cookie / 代理配置') + return false + } ElMessage.success('登录成功') - - // 跳转到之前的页面或首页 const redirect = router.currentRoute.value.query.redirect as string - router.push(redirect || '/') + await router.push(redirect || '/') return true } diff --git a/flash-sale-frontend/src/styles/index.scss b/flash-sale-frontend/src/styles/index.scss index 7105cb7..f2d47ae 100644 --- a/flash-sale-frontend/src/styles/index.scss +++ b/flash-sale-frontend/src/styles/index.scss @@ -2,49 +2,120 @@ @tailwind components; @tailwind utilities; -// 自定义变量 :root { - --primary-color: #ef4444; - --success-color: #10b981; - --warning-color: #f59e0b; - --danger-color: #ef4444; - --info-color: #3b82f6; + --tone-0: #fffdf8; + --tone-50: #f7f2ea; + --tone-100: #efe7dc; + --tone-200: #d8cebf; + --tone-300: #c4b7a4; + --tone-400: #9a8b76; + --tone-500: #746855; + --tone-600: #5c5346; + --tone-700: #433d34; + --tone-800: #2d2a25; + --tone-900: #171614; + --surface-muted: #f4ede4; + --surface-raised: #fffaf2; + --line-soft: #d8cebf; + --line-strong: #171614; + --shadow-soft: 0 14px 34px rgba(23, 22, 20, 0.06); + --shadow-strong: 0 18px 40px rgba(23, 22, 20, 0.1); + --radius-xl: 24px; + --radius-lg: 20px; + --radius-md: 16px; + + --primary-color: var(--tone-900); + --success-color: var(--tone-700); + --warning-color: var(--tone-600); + --danger-color: var(--tone-900); + --info-color: var(--tone-500); + + --el-color-primary: var(--tone-900); + --el-color-primary-light-3: var(--tone-700); + --el-color-primary-light-5: var(--tone-600); + --el-color-primary-light-7: var(--tone-400); + --el-color-primary-light-8: var(--tone-300); + --el-color-primary-light-9: var(--tone-100); + --el-color-primary-dark-2: #0f0f0d; + --el-color-success: var(--tone-700); + --el-color-success-light-9: var(--tone-100); + --el-color-warning: var(--tone-600); + --el-color-warning-light-9: var(--tone-100); + --el-color-danger: var(--tone-900); + --el-color-danger-light-9: var(--tone-100); + --el-color-info: var(--tone-500); + --el-color-info-light-9: var(--tone-100); + --el-border-color: var(--line-soft); + --el-border-color-light: var(--line-soft); + --el-border-color-lighter: var(--tone-100); + --el-fill-color-light: var(--surface-muted); + --el-fill-color-blank: var(--tone-0); + --el-bg-color: var(--tone-0); + --el-bg-color-page: var(--tone-50); + --el-text-color-primary: var(--tone-900); + --el-text-color-regular: var(--tone-700); + --el-text-color-secondary: var(--tone-500); + --el-text-color-placeholder: var(--tone-400); + --el-mask-color: rgba(23, 23, 21, 0.52); + --el-box-shadow-light: var(--shadow-soft); } -// 全局样式重置 * { margin: 0; padding: 0; box-sizing: border-box; } -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', - 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +html { + background: var(--tone-50); +} + +body { + font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--tone-900); + background: + radial-gradient(circle at top, rgba(255, 253, 248, 0.88), rgba(255, 253, 248, 0) 26%), + linear-gradient(180deg, var(--tone-50) 0%, #f2ebdf 100%); +} + +#app { + min-height: 100vh; + background: transparent; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; } -// 滚动条样式 ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { - background: #f1f1f1; + background: var(--tone-50); } ::-webkit-scrollbar-thumb { - background: #888; + background: #b8ab90; border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: #555; + background: #8c7e6b; } -// 动画类 @keyframes shake { 0%, 100% { transform: translateX(0); @@ -61,34 +132,383 @@ body { animation: shake 0.5s; } -// 工具类 .text-gradient { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; + color: var(--tone-900); } .card-shadow { - box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); - transition: all 0.3s; - + border: 1px solid var(--line-soft); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; + &:hover { - box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15); + border-color: var(--line-strong); + box-shadow: var(--shadow-strong); transform: translateY(-2px); } } -// Element Plus 样式覆盖 +:where(.el-button, .el-input__wrapper, .el-select__wrapper, .el-textarea__inner, .el-dialog, + .el-card, .el-popover, .el-message-box, .el-notification, .el-alert, .el-tag, .el-table, + .el-empty, .el-menu-item, .el-sub-menu__title, .el-radio-button__inner, .el-pagination button) { + transition: all 0.2s ease; +} + +.el-button { + border-radius: 999px !important; + font-weight: 600; + box-shadow: none !important; + border-width: 1px !important; + border-style: solid !important; + border-color: var(--line-strong) !important; + background: var(--surface-raised) !important; + color: var(--tone-900) !important; +} + +.el-button--primary, .el-button--danger { - background-color: var(--primary-color) !important; - border-color: var(--primary-color) !important; + background-color: var(--surface-raised) !important; + border-color: var(--line-strong) !important; + color: var(--tone-900) !important; } -.el-message-box { - border-radius: 8px; +.el-button:hover, +.el-button:focus, +.el-button--primary:hover, +.el-button--primary:focus, +.el-button--danger:hover, +.el-button--danger:focus { + background-color: var(--tone-900) !important; + border-color: var(--tone-900) !important; + color: #ffffff !important; } +.el-button.is-text, +.el-button--text { + color: var(--tone-900) !important; + background: transparent !important; + border-color: transparent !important; + padding-left: 4px !important; + padding-right: 4px !important; +} + +.el-button.is-text:hover, +.el-button--text:hover { + background: transparent !important; + color: var(--tone-700) !important; +} + +.el-button--default:hover, +.el-button--default:focus { + background: var(--tone-50) !important; + color: var(--tone-900) !important; + border-color: var(--line-strong) !important; +} + +.el-input__wrapper, +.el-select__wrapper, +.el-textarea__inner, +.el-date-editor.el-input__wrapper, +.el-date-editor .el-input__wrapper { + background: var(--surface-raised) !important; + border-radius: 14px !important; + box-shadow: inset 0 0 0 1px var(--line-soft) !important; +} + +.el-input__wrapper.is-focus, +.el-select__wrapper.is-focused, +.el-textarea__inner:focus { + box-shadow: inset 0 0 0 1px var(--line-strong) !important; +} + +.el-radio-button__inner { + border-radius: 14px !important; + border-color: var(--line-soft) !important; + color: var(--tone-900) !important; + background: var(--surface-raised) !important; + box-shadow: none !important; +} + +.el-radio-button__original-radio:checked + .el-radio-button__inner { + background: var(--tone-0) !important; + border-color: var(--line-strong) !important; + color: var(--tone-900) !important; + box-shadow: inset 0 0 0 1px var(--tone-900) !important; +} + +.el-card, +.el-dialog, +.el-popover, +.el-message-box, .el-notification { - border-radius: 8px; -} \ No newline at end of file + border: 1px solid var(--line-soft) !important; + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-soft) !important; + background: var(--surface-raised) !important; + overflow: hidden; +} + +.el-dialog__header, +.el-message-box__header { + margin: 0; + padding-bottom: 12px; +} + +.el-dialog__body { + color: var(--tone-700); +} + +.el-table { + --el-table-border-color: var(--line-soft); + --el-table-header-bg-color: var(--surface-muted); + --el-table-row-hover-bg-color: var(--tone-50); + overflow: hidden; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: var(--surface-raised); +} + +.el-table th.el-table__cell { + background: var(--surface-muted); + color: var(--tone-900); + font-weight: 600; +} + +.el-table tr { + color: var(--tone-800); +} + +.el-tag, +.el-tag--success, +.el-tag--warning, +.el-tag--danger, +.el-tag--info, +.el-tag--primary { + background: var(--surface-raised) !important; + border-color: var(--line-soft) !important; + color: var(--tone-700) !important; + border-radius: 999px !important; +} + +.el-alert, +.el-alert--success, +.el-alert--warning, +.el-alert--error, +.el-alert--info { + background: var(--surface-raised) !important; + border: 1px solid var(--line-soft) !important; + color: var(--tone-900) !important; +} + +.el-tabs__item { + color: var(--tone-500) !important; +} + +.el-tabs__item.is-active, +.el-tabs__item:hover { + color: var(--tone-900) !important; +} + +.el-tabs__active-bar { + background: var(--tone-900) !important; +} + +.el-progress-bar__outer { + background: var(--surface-muted) !important; + box-shadow: inset 0 0 0 1px var(--line-soft) !important; +} + +.el-progress-bar__inner { + background: var(--tone-900) !important; +} + +.el-menu { + --el-menu-bg-color: transparent; + --el-menu-hover-bg-color: var(--surface-raised); + --el-menu-active-color: var(--tone-900); + border-right: none !important; +} + +.el-sub-menu__title:hover, +.el-menu-item:hover { + background: var(--surface-muted) !important; +} + +.el-menu-item.is-active { + background: var(--surface-raised) !important; + color: var(--tone-900) !important; + box-shadow: inset 0 0 0 1px var(--line-strong); +} + +.el-pagination { + --el-pagination-button-bg-color: transparent; + --el-pagination-hover-color: var(--tone-900); +} + +.el-pagination .btn-prev, +.el-pagination .btn-next, +.el-pagination .el-pager li { + border-radius: 999px; + border: 1px solid var(--line-soft); + background: var(--surface-raised); +} + +.el-pagination .el-pager li.is-active { + background: var(--surface-raised) !important; + color: var(--tone-900) !important; + font-weight: 700; + border-color: var(--line-strong); +} + +.el-badge__content { + background: var(--surface-raised) !important; + border-color: var(--line-strong) !important; + color: var(--tone-900) !important; +} + +.el-breadcrumb__inner.is-link, +.el-breadcrumb__inner a { + color: var(--tone-500) !important; +} + +.el-breadcrumb__inner { + color: var(--tone-700) !important; +} + +.el-step__title.is-process, +.el-step__title.is-finish, +.el-step__icon.is-process, +.el-step__icon.is-finish { + color: var(--tone-900) !important; + border-color: var(--tone-900) !important; +} + +.el-step__head.is-process, +.el-step__head.is-finish, +.el-step__line-inner { + border-color: var(--tone-900) !important; + background-color: var(--tone-900) !important; +} + +.el-empty__description p { + color: var(--tone-500) !important; +} + +.el-rate__icon.is-active { + color: var(--tone-800) !important; +} + +.text-red-500, +.text-blue-500, +.text-green-500, +.text-orange-500, +.text-purple-500, +.text-pink-500, +.text-rose-500, +.text-yellow-500, +.text-emerald-500, +.text-blue-700, +.text-blue-900 { + color: var(--tone-700) !important; +} + +.bg-red-50, +.bg-blue-50, +.bg-yellow-50, +.bg-orange-50, +.bg-green-50, +.bg-purple-50, +.bg-pink-50, +.bg-rose-50, +.bg-emerald-50, +.bg-blue-100, +.bg-emerald-100, +.bg-orange-100, +.bg-rose-100 { + background-color: var(--surface-muted) !important; +} + +.from-red-500, +.from-orange-400, +.from-green-400, +.from-purple-400, +.from-yellow-400, +.from-blue-500, +.from-blue-400, +.from-pink-500 { + --tw-gradient-from: #ffffff var(--tw-gradient-from-position) !important; + --tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position) !important; + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important; +} + +.to-red-500, +.to-blue-500, +.to-pink-500, +.to-orange-500, +.to-blue-400, +.to-orange-400 { + --tw-gradient-to: #ffffff var(--tw-gradient-to-position) !important; +} + +.border-primary-500 { + border-color: var(--tone-900) !important; +} + +.hover\:text-primary-500:hover, +.hover\:text-blue-500:hover, +.hover\:text-red-500:hover { + color: var(--tone-900) !important; +} + +.mini-stat, +.stat-card, +.panel-card, +.feature-card, +.price-card, +.note-card, +.rules-card, +.service-row, +.business-item, +.log-row, +.brand-icon, +.brand-tag, +.cart-link, +.user-trigger, +.notification-center .notification-trigger, +.discount-badge, +.discount-pill, +.time-block { + background: var(--surface-raised) !important; + color: var(--tone-900) !important; + border: 1px solid var(--line-soft) !important; + box-shadow: var(--shadow-soft) !important; + border-radius: var(--radius-lg) !important; + overflow: hidden; +} + +.mini-stat__value, +.mini-stat__label, +.stat-value, +.stat-label, +.stat-desc, +.panel-title, +.panel-subtitle { + color: var(--tone-900) !important; + opacity: 1 !important; +} + +.stat-icon { + background: var(--surface-muted) !important; + color: var(--tone-900) !important; + border: 1px solid var(--line-soft) !important; +} + +.log-time { + color: var(--tone-500) !important; +} + +.search-highlight { + color: var(--tone-900); + font-weight: 700; +} diff --git a/flash-sale-frontend/src/types/api.d.ts b/flash-sale-frontend/src/types/api.d.ts index 73a52dd..750f910 100644 --- a/flash-sale-frontend/src/types/api.d.ts +++ b/flash-sale-frontend/src/types/api.d.ts @@ -155,4 +155,65 @@ export interface Statistics { todaySales: number activeFlashSales: number onlineUsers: number +} + +// 拼团活动类型 +export interface GroupBuying { + id: number + productId: number + productName: string + productImageUrl: string + productPrice: number + groupPrice: number + requiredMembers: number + durationMinutes: number + totalStock: number + remainingStock: number + maxPerUser: number + status: 'DRAFT' | 'UPCOMING' | 'ACTIVE' | 'ENDED' + statusDescription: string + startTime: string + endTime: string + createdAt: string + updatedAt: string + activeGroupCount: number + discount: number +} + +// 拼团团组类型 +export interface GroupBuyingGroup { + id: number + groupNo: string + groupBuyingId: number + leaderUserId: number + leaderUsername: string + requiredMembers: number + currentMembers: number + status: 'FORMING' | 'SUCCESS' | 'FAILED' + statusDescription: string + expireTime: string + createdAt: string + completedAt?: string + members: GroupBuyingMember[] + groupBuying?: GroupBuying +} + +// 拼团成员类型 +export interface GroupBuyingMember { + id: number + userId: number + username: string + avatar?: string + orderId?: number + status: number + joinedAt: string +} + +// 拼团统计 +export interface GroupBuyingStatistics { + totalActivities: number + activeActivities: number + myGroups: number + successGroups: number + totalSaved: number } \ No newline at end of file diff --git a/flash-sale-frontend/src/utils/normalizers.ts b/flash-sale-frontend/src/utils/normalizers.ts index fdb4d51..02d261a 100644 --- a/flash-sale-frontend/src/utils/normalizers.ts +++ b/flash-sale-frontend/src/utils/normalizers.ts @@ -1,6 +1,8 @@ import type { CartItem, FlashSale, + GroupBuying, + GroupBuyingGroup, Order, OrderAddress, PageResponse, @@ -285,3 +287,69 @@ export const normalizeAdminProduct = (product: Record): AdminProduc viewCount: toNumber(product.viewCount), rating: toNumber(product.rating), }) + +export const mapGroupBuyingStatus = (status: number | string): GroupBuying['status'] => { + const value = typeof status === 'string' ? status : toNumber(status) + if (value === 'DRAFT' || value === 0) return 'DRAFT' + if (value === 'UPCOMING' || value === 1) return 'UPCOMING' + if (value === 'ACTIVE' || value === 2) return 'ACTIVE' + if (value === 'ENDED' || value === 3) return 'ENDED' + return 'DRAFT' +} + +export const mapGroupStatus = (status: number | string): GroupBuyingGroup['status'] => { + const value = typeof status === 'string' ? status : toNumber(status) + if (value === 'FORMING' || value === 1) return 'FORMING' + if (value === 'SUCCESS' || value === 2) return 'SUCCESS' + if (value === 'FAILED' || value === 3) return 'FAILED' + return 'FORMING' +} + +export const normalizeGroupBuying = (gb: Record): GroupBuying => ({ + id: toNumber(gb.id), + productId: toNumber(gb.productId), + productName: toString(gb.productName), + productImageUrl: resolveImageUrl(toString(gb.productImageUrl, '')), + productPrice: toNumber(gb.productPrice), + groupPrice: toNumber(gb.groupPrice), + requiredMembers: toNumber(gb.requiredMembers, 2), + durationMinutes: toNumber(gb.durationMinutes, 1440), + totalStock: toNumber(gb.totalStock), + remainingStock: toNumber(gb.remainingStock), + maxPerUser: toNumber(gb.maxPerUser, 1), + status: mapGroupBuyingStatus(gb.status), + statusDescription: toString(gb.statusDescription), + startTime: toIsoLikeString(gb.startTime), + endTime: toIsoLikeString(gb.endTime), + createdAt: toIsoLikeString(gb.createdAt), + updatedAt: toIsoLikeString(gb.updatedAt || gb.createdAt), + activeGroupCount: toNumber(gb.activeGroupCount), + discount: toNumber(gb.discount), +}) + +export const normalizeGroupBuyingGroup = (group: Record): GroupBuyingGroup => ({ + id: toNumber(group.id), + groupNo: toString(group.groupNo), + groupBuyingId: toNumber(group.groupBuyingId), + leaderUserId: toNumber(group.leaderUserId), + leaderUsername: toString(group.leaderUsername), + requiredMembers: toNumber(group.requiredMembers, 2), + currentMembers: toNumber(group.currentMembers, 1), + status: mapGroupStatus(group.status), + statusDescription: toString(group.statusDescription), + expireTime: toIsoLikeString(group.expireTime), + createdAt: toIsoLikeString(group.createdAt), + completedAt: group.completedAt ? toIsoLikeString(group.completedAt) : undefined, + members: Array.isArray(group.members) + ? group.members.map((m: Record) => ({ + id: toNumber(m.id), + userId: toNumber(m.userId), + username: toString(m.username), + avatar: resolveImageUrl(toString(m.avatar, '')), + orderId: m.orderId ? toNumber(m.orderId) : undefined, + status: toNumber(m.status), + joinedAt: toIsoLikeString(m.joinedAt), + })) + : [], + groupBuying: group.groupBuying ? normalizeGroupBuying(group.groupBuying) : undefined, +}) diff --git a/flash-sale-frontend/tailwind.config.js b/flash-sale-frontend/tailwind.config.js index f35f4ae..9c70ba1 100644 --- a/flash-sale-frontend/tailwind.config.js +++ b/flash-sale-frontend/tailwind.config.js @@ -8,16 +8,16 @@ export default { extend: { colors: { primary: { - 50: '#fef2f2', - 100: '#fee2e2', - 200: '#fecaca', - 300: '#fca5a5', - 400: '#f87171', - 500: '#ef4444', - 600: '#dc2626', - 700: '#b91c1c', - 800: '#991b1b', - 900: '#7f1d1d', + 50: '#f7f7f6', + 100: '#efefed', + 200: '#dfdfdc', + 300: '#c6c6c2', + 400: '#9f9f99', + 500: '#7b7b74', + 600: '#5e5e58', + 700: '#44443f', + 800: '#2b2b27', + 900: '#171715', }, }, animation: { @@ -26,4 +26,4 @@ export default { }, }, plugins: [], -} \ No newline at end of file +} diff --git a/flash-sale-frontend/vite.config.ts b/flash-sale-frontend/vite.config.ts index d941e0b..2439f8a 100644 --- a/flash-sale-frontend/vite.config.ts +++ b/flash-sale-frontend/vite.config.ts @@ -31,6 +31,13 @@ export default defineConfig({ }, }, }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler', + }, + }, + }, build: { rollupOptions: { output: { diff --git a/src/main/java/com/org/flashsalesystem/FlashSaleSystemApplication.java b/src/main/java/com/org/flashsalesystem/FlashSaleSystemApplication.java index 06d8c58..ad5f1ef 100644 --- a/src/main/java/com/org/flashsalesystem/FlashSaleSystemApplication.java +++ b/src/main/java/com/org/flashsalesystem/FlashSaleSystemApplication.java @@ -2,8 +2,10 @@ package com.org.flashsalesystem; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class FlashSaleSystemApplication { public static void main(String[] args) { diff --git a/src/main/java/com/org/flashsalesystem/config/RedissonConfig.java b/src/main/java/com/org/flashsalesystem/config/RedissonConfig.java index 9f4b555..93a2bc8 100644 --- a/src/main/java/com/org/flashsalesystem/config/RedissonConfig.java +++ b/src/main/java/com/org/flashsalesystem/config/RedissonConfig.java @@ -292,4 +292,16 @@ public class RedissonConfig { log.info("加载购物车操作Lua脚本"); return script; } + + /** + * 拼团库存扣减Lua脚本 + */ + @Bean + public DefaultRedisScript groupBuyingStockScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/groupbuying_stock.lua"))); + script.setResultType(Long.class); + log.info("加载拼团库存扣减Lua脚本"); + return script; + } } diff --git a/src/main/java/com/org/flashsalesystem/controller/FlashSaleController.java b/src/main/java/com/org/flashsalesystem/controller/FlashSaleController.java index 5dde93a..95f916f 100644 --- a/src/main/java/com/org/flashsalesystem/controller/FlashSaleController.java +++ b/src/main/java/com/org/flashsalesystem/controller/FlashSaleController.java @@ -150,6 +150,31 @@ public class FlashSaleController { } } + /** + * 获取秒杀活动统计信息 + */ + @GetMapping("/statistics") + public ResponseEntity> getFlashSaleStatistics(HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + Map stats = flashSaleService.getFlashSaleStatistics(userId); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", stats); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取秒杀统计失败", e); + + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", e.getMessage()); + + return ResponseEntity.badRequest().body(response); + } + } + /** * 获取秒杀活动详情 */ diff --git a/src/main/java/com/org/flashsalesystem/controller/GroupBuyingController.java b/src/main/java/com/org/flashsalesystem/controller/GroupBuyingController.java new file mode 100644 index 0000000..6d48807 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/controller/GroupBuyingController.java @@ -0,0 +1,277 @@ +package com.org.flashsalesystem.controller; + +import com.org.flashsalesystem.dto.GroupBuyingDTO; +import com.org.flashsalesystem.dto.UserDTO; +import com.org.flashsalesystem.service.GroupBuyingService; +import com.org.flashsalesystem.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "拼团管理", description = "拼团活动创建、参与、团组管理等接口") +@RestController +@RequestMapping("/api/groupbuying") +@Slf4j +public class GroupBuyingController { + + @Autowired + private GroupBuyingService groupBuyingService; + + @Autowired + private UserService userService; + + // ========== 用户端 ========== + + @Operation(summary = "获取拼团活动列表") + @GetMapping("/list") + public ResponseEntity> getList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) Integer status) { + try { + Map result = groupBuyingService.getGroupBuyingList(page, size, status); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", result); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取拼团活动列表失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "获取拼团活动详情") + @GetMapping("/{id}") + public ResponseEntity> getDetail(@PathVariable Long id) { + try { + GroupBuyingDTO detail = groupBuyingService.getGroupBuyingDetail(id); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", detail); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取拼团活动详情失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "获取活动下的团组列表") + @GetMapping("/{id}/groups") + public ResponseEntity> getGroups( + @PathVariable Long id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + try { + Map result = groupBuyingService.getGroupsByActivity(id, page, size); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", result); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取团组列表失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "参与拼团") + @PostMapping("/join") + public ResponseEntity> joinGroup( + @Validated @RequestBody GroupBuyingDTO.JoinGroupDTO joinDTO, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return createUnauthorizedResponse(); + } + + GroupBuyingDTO.JoinResultDTO result = groupBuyingService.joinGroupBuying(joinDTO, userId); + + Map response = new HashMap<>(); + response.put("success", result.getSuccess()); + response.put("message", result.getMessage()); + response.put("data", result); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("参与拼团失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "获取团组详情") + @GetMapping("/group/{groupId}") + public ResponseEntity> getGroupDetail(@PathVariable Long groupId) { + try { + GroupBuyingDTO.GroupInfoDTO detail = groupBuyingService.getGroupDetail(groupId); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", detail); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取团组详情失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "退出团组") + @PostMapping("/group/{groupId}/cancel") + public ResponseEntity> cancelMembership( + @PathVariable Long groupId, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return createUnauthorizedResponse(); + } + + groupBuyingService.cancelMembership(groupId, userId); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "已退出团组"); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("退出团组失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "我的团组") + @GetMapping("/my-groups") + public ResponseEntity> getMyGroups( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return createUnauthorizedResponse(); + } + + Map result = groupBuyingService.getMyGroups(userId, page, size); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", result); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取我的团组失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "拼团统计数据") + @GetMapping("/statistics") + public ResponseEntity> getStatistics(HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + GroupBuyingDTO.StatisticsDTO stats = groupBuyingService.getStatistics(userId); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", stats); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取拼团统计失败", e); + return badRequest(e.getMessage()); + } + } + + // ========== 管理员端 ========== + + @Operation(summary = "创建拼团活动") + @PostMapping("/admin/create") + public ResponseEntity> create( + @Validated @RequestBody GroupBuyingDTO.CreateDTO createDTO) { + try { + GroupBuyingDTO result = groupBuyingService.createGroupBuying(createDTO); + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "拼团活动创建成功"); + response.put("data", result); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("创建拼团活动失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "更新拼团活动") + @PutMapping("/admin/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @Validated @RequestBody GroupBuyingDTO.UpdateDTO updateDTO) { + try { + GroupBuyingDTO result = groupBuyingService.updateGroupBuying(id, updateDTO); + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "拼团活动更新成功"); + response.put("data", result); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("更新拼团活动失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "删除拼团活动") + @DeleteMapping("/admin/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + try { + boolean success = groupBuyingService.deleteGroupBuying(id); + Map response = new HashMap<>(); + response.put("success", success); + response.put("message", success ? "拼团活动删除成功" : "拼团活动删除失败"); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("删除拼团活动失败", e); + return badRequest(e.getMessage()); + } + } + + @Operation(summary = "预热所有拼团活动库存") + @PostMapping("/admin/preload-all") + public ResponseEntity> preloadAll() { + try { + groupBuyingService.preloadAllActiveStock(); + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "拼团活动库存预热完成"); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("预热拼团库存失败", e); + return badRequest(e.getMessage()); + } + } + + // ========== 工具方法 ========== + + private Long getCurrentUserId(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) return null; + String token = (String) session.getAttribute("token"); + UserDTO user = userService.getUserByToken(token); + return user != null ? user.getId() : null; + } + + private ResponseEntity> createUnauthorizedResponse() { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "用户未登录或登录已过期"); + return ResponseEntity.status(401).body(response); + } + + private ResponseEntity> badRequest(String message) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", message); + return ResponseEntity.badRequest().body(response); + } +} diff --git a/src/main/java/com/org/flashsalesystem/controller/NotificationController.java b/src/main/java/com/org/flashsalesystem/controller/NotificationController.java new file mode 100644 index 0000000..da45498 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/controller/NotificationController.java @@ -0,0 +1,116 @@ +package com.org.flashsalesystem.controller; + +import com.org.flashsalesystem.dto.UserDTO; +import com.org.flashsalesystem.service.NotificationService; +import com.org.flashsalesystem.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/notification") +public class NotificationController { + + @Autowired + private NotificationService notificationService; + + @Autowired + private UserService userService; + + @GetMapping("/list") + public ResponseEntity> getNotifications( + @RequestParam(required = false) String type, + HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return unauthorized(); + } + + Map response = new HashMap<>(); + response.put("success", true); + if (type != null && !type.isEmpty()) { + response.put("data", notificationService.getUserNotificationsByType(userId, type)); + } else { + response.put("data", notificationService.getUserNotifications(userId)); + } + return ResponseEntity.ok(response); + } + + @GetMapping("/unread-count") + public ResponseEntity> getUnreadCount(HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return unauthorized(); + } + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", notificationService.getUnreadCount(userId)); + return ResponseEntity.ok(response); + } + + @PutMapping("/{id}/read") + public ResponseEntity> markAsRead(@PathVariable Long id, + HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return unauthorized(); + } + + notificationService.markAsRead(id, userId); + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "已标记为已读"); + return ResponseEntity.ok(response); + } + + @PutMapping("/read-all") + public ResponseEntity> markAllAsRead(HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return unauthorized(); + } + + notificationService.markAllAsRead(userId); + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "已全部标记为已读"); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/clear") + public ResponseEntity> clearAll(HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return unauthorized(); + } + + notificationService.clearAll(userId); + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "已清空所有通知"); + return ResponseEntity.ok(response); + } + + private Long getCurrentUserId(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + String token = (String) session.getAttribute("token"); + UserDTO user = userService.getUserByToken(token); + return user != null ? user.getId() : null; + } + + private ResponseEntity> unauthorized() { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "用户未登录或登录已过期"); + return ResponseEntity.status(401).body(response); + } +} diff --git a/src/main/java/com/org/flashsalesystem/controller/ProductReviewController.java b/src/main/java/com/org/flashsalesystem/controller/ProductReviewController.java index 9703f4a..687b44b 100644 --- a/src/main/java/com/org/flashsalesystem/controller/ProductReviewController.java +++ b/src/main/java/com/org/flashsalesystem/controller/ProductReviewController.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import java.util.HashMap; +import java.util.List; import java.util.Map; @RestController @@ -41,10 +42,52 @@ public class ProductReviewController { return unauthorized(); } + Map response = new HashMap<>(); + try { + response.put("success", true); + response.put("message", "评价提交成功"); + response.put("data", productReviewService.createReview(userId, createDTO)); + return ResponseEntity.ok(response); + } catch (RuntimeException e) { + response.put("success", false); + response.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(response); + } + } + + @GetMapping("/check") + public ResponseEntity> checkReview(@RequestParam Long orderId, + @RequestParam Long productId) { Map response = new HashMap<>(); response.put("success", true); - response.put("message", "评价提交成功"); - response.put("data", productReviewService.createReview(userId, createDTO)); + response.put("data", productReviewService.checkReviewStatus(orderId, productId)); + return ResponseEntity.ok(response); + } + + @GetMapping("/my") + public ResponseEntity> getMyReviews(HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return unauthorized(); + } + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", productReviewService.getUserReviews(userId)); + return ResponseEntity.ok(response); + } + + @GetMapping("/order/{orderId}") + public ResponseEntity> getOrderReviews(@PathVariable Long orderId, + HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return unauthorized(); + } + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", productReviewService.getOrderReviews(orderId)); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java b/src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java new file mode 100644 index 0000000..70cdd2e --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java @@ -0,0 +1,173 @@ +package com.org.flashsalesystem.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GroupBuyingDTO { + + private Long id; + private Long productId; + private String productName; + private String productImageUrl; + private BigDecimal productPrice; + private BigDecimal groupPrice; + private Integer requiredMembers; + private Integer durationMinutes; + private Integer totalStock; + private Integer remainingStock; + private Integer maxPerUser; + private Integer status; + private String statusDescription; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime startTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime endTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updatedAt; + + private Integer activeGroupCount; + private BigDecimal discount; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CreateDTO { + @NotNull(message = "商品ID不能为空") + private Long productId; + + @NotNull(message = "拼团价格不能为空") + @DecimalMin(value = "0.01", message = "拼团价格必须大于0") + private BigDecimal groupPrice; + + @Min(value = 2, message = "成团人数至少为2人") + private Integer requiredMembers = 2; + + @Min(value = 1, message = "有效期至少为1分钟") + private Integer durationMinutes = 1440; + + @Min(value = 1, message = "总库存至少为1") + private Integer totalStock; + + @Min(value = 1, message = "每人限购至少为1") + private Integer maxPerUser = 1; + + @NotNull(message = "开始时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime startTime; + + @NotNull(message = "结束时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime endTime; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class UpdateDTO { + private Long productId; + private BigDecimal groupPrice; + private Integer requiredMembers; + private Integer durationMinutes; + private Integer totalStock; + private Integer maxPerUser; + private Integer status; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime startTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime endTime; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class JoinGroupDTO { + @NotNull(message = "拼团活动ID不能为空") + private Long groupBuyingId; + + private Long groupId; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class GroupInfoDTO { + private Long id; + private String groupNo; + private Long groupBuyingId; + private Long leaderUserId; + private String leaderUsername; + private Integer requiredMembers; + private Integer currentMembers; + private Integer status; + private String statusDescription; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime expireTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime completedAt; + + private List members; + private GroupBuyingDTO groupBuying; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class MemberDTO { + private Long id; + private Long userId; + private String username; + private String avatar; + private Long orderId; + private Integer status; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime joinedAt; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class JoinResultDTO { + private Boolean success; + private String message; + private Long groupId; + private String groupNo; + private Long orderId; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class StatisticsDTO { + private Long totalActivities; + private Long activeActivities; + private Long myGroups; + private Long successGroups; + private BigDecimal totalSaved; + } +} diff --git a/src/main/java/com/org/flashsalesystem/dto/OrderDTO.java b/src/main/java/com/org/flashsalesystem/dto/OrderDTO.java index 3a54a0f..5bca457 100644 --- a/src/main/java/com/org/flashsalesystem/dto/OrderDTO.java +++ b/src/main/java/com/org/flashsalesystem/dto/OrderDTO.java @@ -25,6 +25,7 @@ public class OrderDTO { private Long userId; private String username; private Long productId; + private Long flashSaleId; private String productName; private String productImageUrl; private Integer quantity; diff --git a/src/main/java/com/org/flashsalesystem/dto/ProductReviewDTO.java b/src/main/java/com/org/flashsalesystem/dto/ProductReviewDTO.java index 4136a15..3578e5f 100644 --- a/src/main/java/com/org/flashsalesystem/dto/ProductReviewDTO.java +++ b/src/main/java/com/org/flashsalesystem/dto/ProductReviewDTO.java @@ -20,6 +20,8 @@ public class ProductReviewDTO { private Long userId; private Long orderId; private String username; + private String productName; + private String productImage; private Integer rating; private String content; private Integer status; @@ -64,4 +66,12 @@ public class ProductReviewDTO { private Integer status; private String adminReply; } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CheckDTO { + private boolean reviewed; + private ProductReviewDTO review; + } } diff --git a/src/main/java/com/org/flashsalesystem/entity/GroupBuying.java b/src/main/java/com/org/flashsalesystem/entity/GroupBuying.java new file mode 100644 index 0000000..ab86118 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/entity/GroupBuying.java @@ -0,0 +1,98 @@ +package com.org.flashsalesystem.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "group_buying") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GroupBuying { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "group_price", nullable = false, precision = 10, scale = 2) + private BigDecimal groupPrice; + + @Column(name = "required_members", nullable = false) + private Integer requiredMembers = 2; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes = 1440; + + @Column(name = "total_stock", nullable = false) + private Integer totalStock; + + @Column(name = "remaining_stock", nullable = false) + private Integer remainingStock; + + @Column(name = "max_per_user", nullable = false) + private Integer maxPerUser = 1; + + /** + * 状态:0-草稿 1-未开始 2-进行中 3-已结束 + */ + @Column(nullable = false) + private Integer status = 0; + + @Column(name = "start_time", nullable = false) + private LocalDateTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalDateTime endTime; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", insertable = false, updatable = false) + private Product product; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public boolean isActive() { + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(startTime) && now.isBefore(endTime) && status == 2; + } + + public enum GroupBuyingStatus { + DRAFT(0, "草稿"), + PENDING(1, "未开始"), + ACTIVE(2, "进行中"), + ENDED(3, "已结束"); + + private final int code; + private final String description; + + GroupBuyingStatus(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { return code; } + public String getDescription() { return description; } + } +} diff --git a/src/main/java/com/org/flashsalesystem/entity/GroupBuyingGroup.java b/src/main/java/com/org/flashsalesystem/entity/GroupBuyingGroup.java new file mode 100644 index 0000000..ae2b2a2 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/entity/GroupBuyingGroup.java @@ -0,0 +1,80 @@ +package com.org.flashsalesystem.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "group_buying_group") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GroupBuyingGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_no", nullable = false, unique = true, length = 64) + private String groupNo; + + @Column(name = "group_buying_id", nullable = false) + private Long groupBuyingId; + + @Column(name = "leader_user_id", nullable = false) + private Long leaderUserId; + + @Column(name = "required_members", nullable = false) + private Integer requiredMembers; + + @Column(name = "current_members", nullable = false) + private Integer currentMembers = 1; + + /** + * 状态:1-拼团中 2-已成团 3-已失败(超时) + */ + @Column(nullable = false) + private Integer status = 1; + + @Column(name = "expire_time", nullable = false) + private LocalDateTime expireTime; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_buying_id", insertable = false, updatable = false) + private GroupBuying groupBuying; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "leader_user_id", insertable = false, updatable = false) + private User leader; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public enum GroupStatus { + FORMING(1, "拼团中"), + SUCCESS(2, "已成团"), + FAILED(3, "已失败"); + + private final int code; + private final String description; + + GroupStatus(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { return code; } + public String getDescription() { return description; } + } +} diff --git a/src/main/java/com/org/flashsalesystem/entity/GroupBuyingMember.java b/src/main/java/com/org/flashsalesystem/entity/GroupBuyingMember.java new file mode 100644 index 0000000..104b57b --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/entity/GroupBuyingMember.java @@ -0,0 +1,53 @@ +package com.org.flashsalesystem.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "group_buying_member", uniqueConstraints = { + @UniqueConstraint(name = "uk_group_user", columnNames = {"group_id", "user_id"}) +}) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GroupBuyingMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_id", nullable = false) + private Long groupId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "order_id") + private Long orderId; + + /** + * 状态:1-已加入 2-已成团 3-已退出 + */ + @Column(nullable = false) + private Integer status = 1; + + @Column(name = "joined_at", nullable = false, updatable = false) + private LocalDateTime joinedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", insertable = false, updatable = false) + private GroupBuyingGroup group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", insertable = false, updatable = false) + private User user; + + @PrePersist + protected void onCreate() { + joinedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/org/flashsalesystem/entity/Notification.java b/src/main/java/com/org/flashsalesystem/entity/Notification.java new file mode 100644 index 0000000..fc1685a --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/entity/Notification.java @@ -0,0 +1,49 @@ +package com.org.flashsalesystem.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "notifications", indexes = { + @Index(name = "idx_notification_user_read", columnList = "user_id, is_read"), + @Index(name = "idx_notification_user_created", columnList = "user_id, created_at") +}) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 32) + private String type; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String message; + + @Column(length = 255) + private String link; + + @Column(name = "is_read", nullable = false) + private Boolean read = false; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/org/flashsalesystem/entity/Order.java b/src/main/java/com/org/flashsalesystem/entity/Order.java index 84d91da..931221a 100644 --- a/src/main/java/com/org/flashsalesystem/entity/Order.java +++ b/src/main/java/com/org/flashsalesystem/entity/Order.java @@ -40,6 +40,12 @@ public class Order { @Column(name = "product_id", nullable = false) private Long productId; + @Column(name = "flash_sale_id") + private Long flashSaleId; + + @Column(name = "group_buying_group_id") + private Long groupBuyingGroupId; + @Min(value = 1, message = "商品数量必须大于0") @Column(nullable = false) private Integer quantity; @@ -145,7 +151,8 @@ public class Order { */ public enum OrderType { NORMAL(1, "普通订单"), - FLASH_SALE(2, "秒杀订单"); + FLASH_SALE(2, "秒杀订单"), + GROUP_BUYING(3, "拼团订单"); private final int code; private final String description; diff --git a/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java b/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java index 94296a6..0f195e4 100644 --- a/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java +++ b/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java @@ -11,7 +11,6 @@ import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; /** * 秒杀活动数据访问层 @@ -19,16 +18,18 @@ import java.util.Optional; @Repository public interface FlashSaleRepository extends JpaRepository { - /** - * 根据商品ID查找秒杀活动 - */ - Optional findByProductId(Long productId); - /** * 分页查找指定商品的秒杀活动 */ Page findByProductId(Long productId, Pageable pageable); + /** + * 查找指定时间点覆盖的商品秒杀活动 + */ + @Query("SELECT f FROM FlashSale f WHERE f.productId = :productId AND f.startTime <= :targetTime AND f.endTime >= :targetTime") + List findByProductIdAndCoveringTime(@Param("productId") Long productId, + @Param("targetTime") LocalDateTime targetTime); + /** * 根据商品ID和状态查找秒杀活动 */ @@ -78,6 +79,13 @@ public interface FlashSaleRepository extends JpaRepository { " >= :quantity") int updateFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity); + /** + * 恢复秒杀库存(订单取消时使用) + */ + @Modifying + @Query("UPDATE FlashSale f SET f.flashStock = f.flashStock + :quantity WHERE f.id = :flashSaleId") + int increaseFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity); + /** * 更新秒杀活动状态 */ diff --git a/src/main/java/com/org/flashsalesystem/repository/GroupBuyingGroupRepository.java b/src/main/java/com/org/flashsalesystem/repository/GroupBuyingGroupRepository.java new file mode 100644 index 0000000..2da8398 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/repository/GroupBuyingGroupRepository.java @@ -0,0 +1,47 @@ +package com.org.flashsalesystem.repository; + +import com.org.flashsalesystem.entity.GroupBuyingGroup; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface GroupBuyingGroupRepository extends JpaRepository { + + List findByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status); + + Optional findByGroupNo(String groupNo); + + @Query("SELECT g FROM GroupBuyingGroup g WHERE g.status = 1 AND g.expireTime < :now") + List findExpiredGroups(@Param("now") LocalDateTime now); + + List findByLeaderUserId(Long userId); + + Page findByGroupBuyingId(Long groupBuyingId, Pageable pageable); + + @Query("SELECT g FROM GroupBuyingGroup g WHERE g.id IN " + + "(SELECT m.groupId FROM GroupBuyingMember m WHERE m.userId = :userId AND m.status != 3)") + Page findByMemberUserId(@Param("userId") Long userId, Pageable pageable); + + long countByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status); + + @Modifying + @Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers + 1 WHERE g.id = :id") + int incrementCurrentMembers(@Param("id") Long id); + + @Modifying + @Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers - 1 WHERE g.id = :id AND g.currentMembers > 0") + int decrementCurrentMembers(@Param("id") Long id); + + @Modifying + @Query("UPDATE GroupBuyingGroup g SET g.status = :status, g.completedAt = :completedAt WHERE g.id = :id") + int updateStatusAndCompletedAt(@Param("id") Long id, @Param("status") Integer status, @Param("completedAt") LocalDateTime completedAt); +} diff --git a/src/main/java/com/org/flashsalesystem/repository/GroupBuyingMemberRepository.java b/src/main/java/com/org/flashsalesystem/repository/GroupBuyingMemberRepository.java new file mode 100644 index 0000000..cfefd97 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/repository/GroupBuyingMemberRepository.java @@ -0,0 +1,29 @@ +package com.org.flashsalesystem.repository; + +import com.org.flashsalesystem.entity.GroupBuyingMember; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface GroupBuyingMemberRepository extends JpaRepository { + + List findByGroupId(Long groupId); + + List findByGroupIdAndStatus(Long groupId, Integer status); + + boolean existsByGroupIdAndUserIdAndStatusNot(Long groupId, Long userId, Integer excludeStatus); + + Optional findByGroupIdAndUserId(Long groupId, Long userId); + + long countByGroupIdAndStatusNot(Long groupId, Integer excludeStatus); + + @Modifying + @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); +} diff --git a/src/main/java/com/org/flashsalesystem/repository/GroupBuyingRepository.java b/src/main/java/com/org/flashsalesystem/repository/GroupBuyingRepository.java new file mode 100644 index 0000000..434ba4c --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/repository/GroupBuyingRepository.java @@ -0,0 +1,45 @@ +package com.org.flashsalesystem.repository; + +import com.org.flashsalesystem.entity.GroupBuying; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface GroupBuyingRepository extends JpaRepository { + + @Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2") + List findActiveGroupBuyings(@Param("now") LocalDateTime now); + + @Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2") + Page findActiveGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable); + + @Query("SELECT g FROM GroupBuying g WHERE g.startTime > :now AND g.status = 1") + List findUpcomingGroupBuyings(@Param("now") LocalDateTime now); + + @Query("SELECT g FROM GroupBuying g WHERE g.endTime <= :now OR g.status = 3") + Page findEndedGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable); + + Page findByStatus(Integer status, Pageable pageable); + + @Modifying + @Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock - :quantity WHERE g.id = :id AND g.remainingStock >= :quantity") + int updateStock(@Param("id") Long id, @Param("quantity") Integer quantity); + + @Modifying + @Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock + :quantity WHERE g.id = :id") + int increaseStock(@Param("id") Long id, @Param("quantity") Integer quantity); + + @Modifying + @Query("UPDATE GroupBuying g SET g.status = :status WHERE g.id = :id") + int updateStatus(@Param("id") Long id, @Param("status") Integer status); + + long countByStatus(Integer status); +} diff --git a/src/main/java/com/org/flashsalesystem/repository/NotificationRepository.java b/src/main/java/com/org/flashsalesystem/repository/NotificationRepository.java new file mode 100644 index 0000000..4d0fde3 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/repository/NotificationRepository.java @@ -0,0 +1,30 @@ +package com.org.flashsalesystem.repository; + +import com.org.flashsalesystem.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotificationRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtDesc(Long userId); + + List findByUserIdAndTypeOrderByCreatedAtDesc(Long userId, String type); + + long countByUserIdAndReadFalse(Long userId); + + @Modifying + @Query("UPDATE Notification n SET n.read = true WHERE n.userId = :userId AND n.read = false") + int markAllAsRead(@Param("userId") Long userId); + + @Modifying + @Query("UPDATE Notification n SET n.read = true WHERE n.id = :id AND n.userId = :userId") + int markAsRead(@Param("id") Long id, @Param("userId") Long userId); + + void deleteByUserId(Long userId); +} diff --git a/src/main/java/com/org/flashsalesystem/repository/OrderRepository.java b/src/main/java/com/org/flashsalesystem/repository/OrderRepository.java index 7777714..5226921 100644 --- a/src/main/java/com/org/flashsalesystem/repository/OrderRepository.java +++ b/src/main/java/com/org/flashsalesystem/repository/OrderRepository.java @@ -49,6 +49,23 @@ public interface OrderRepository extends JpaRepository { */ Page findByOrderType(Integer orderType, Pageable pageable); + /** + * 分页查找用户指定类型的订单 + */ + Page findByUserIdAndOrderType(Long userId, Integer orderType, Pageable pageable); + + /** + * 统计用户指定类型的订单数量 + */ + @Query("SELECT COUNT(o) FROM Order o WHERE o.userId = :userId AND o.orderType = :orderType") + Long countByUserIdAndOrderType(@Param("userId") Long userId, @Param("orderType") Integer orderType); + + /** + * 统计用户指定类型且非取消的订单数量(抢购成功) + */ + @Query("SELECT COUNT(o) FROM Order o WHERE o.userId = :userId AND o.orderType = :orderType AND o.status != 5") + Long countByUserIdAndOrderTypeAndStatusNot5(@Param("userId") Long userId, @Param("orderType") Integer orderType); + /** * 查找秒杀订单 */ @@ -116,10 +133,14 @@ public interface OrderRepository extends JpaRepository { List findFlashSaleOrdersByUserId(@Param("userId") Long userId); /** - * 检查用户是否已经购买过指定商品的秒杀 + * 检查用户是否已经参与过指定秒杀活动 */ - @Query("SELECT COUNT(o) > 0 FROM Order o WHERE o.userId = :userId AND o.productId = :productId AND o.orderType = 2") - boolean existsFlashSaleOrder(@Param("userId") Long userId, @Param("productId") Long productId); + boolean existsByUserIdAndFlashSaleIdAndOrderType(Long userId, Long flashSaleId, Integer orderType); + + /** + * 检查指定秒杀活动是否已有订单 + */ + boolean existsByFlashSaleIdAndOrderType(Long flashSaleId, Integer orderType); /** * 根据创建时间范围统计订单数量 diff --git a/src/main/java/com/org/flashsalesystem/repository/ProductReviewRepository.java b/src/main/java/com/org/flashsalesystem/repository/ProductReviewRepository.java index 92ce6c6..c1b4b6e 100644 --- a/src/main/java/com/org/flashsalesystem/repository/ProductReviewRepository.java +++ b/src/main/java/com/org/flashsalesystem/repository/ProductReviewRepository.java @@ -18,6 +18,16 @@ public interface ProductReviewRepository extends JpaRepository findByUserIdOrderByCreatedAtDesc(Long userId); + + List findByOrderId(Long orderId); + + boolean existsByOrderIdAndProductId(Long orderId, Long productId); + + Optional findByOrderIdAndProductId(Long orderId, Long productId); } diff --git a/src/main/java/com/org/flashsalesystem/service/CartService.java b/src/main/java/com/org/flashsalesystem/service/CartService.java index 8049338..fedf897 100644 --- a/src/main/java/com/org/flashsalesystem/service/CartService.java +++ b/src/main/java/com/org/flashsalesystem/service/CartService.java @@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; @@ -318,6 +320,7 @@ public class CartService { /** * 购物车下单 */ + @Transactional public OrderDTO checkoutCart(Long userId, List productIds) { log.info("购物车下单: 用户ID={}, 商品IDs={}", userId, productIds); diff --git a/src/main/java/com/org/flashsalesystem/service/FlashSaleService.java b/src/main/java/com/org/flashsalesystem/service/FlashSaleService.java index fa5d00a..dada353 100644 --- a/src/main/java/com/org/flashsalesystem/service/FlashSaleService.java +++ b/src/main/java/com/org/flashsalesystem/service/FlashSaleService.java @@ -16,6 +16,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @@ -90,10 +91,9 @@ public class FlashSaleService { throw new RuntimeException("开始时间不能早于当前时间"); } - // 检查是否已有该商品的秒杀活动 - Optional existingFlashSale = flashSaleRepository.findByProductId(createDTO.getProductId()); - if (existingFlashSale.isPresent()) { - throw new RuntimeException("该商品已有秒杀活动"); + // 验证秒杀价格必须小于商品原价 + if (createDTO.getFlashPrice().compareTo(product.getPrice()) >= 0) { + throw new RuntimeException("秒杀价格必须小于商品原价"); } // 创建秒杀活动 @@ -157,8 +157,8 @@ public class FlashSaleService { } // 检查数据库中是否已有订单 - if (orderRepository.existsFlashSaleOrder(userId, flashSale.getProductId())) { - return createFailResult("您已经购买过该商品"); + if (orderRepository.existsByUserIdAndFlashSaleIdAndOrderType(userId, flashSale.getId(), 2)) { + return createFailResult("您已经参与过该秒杀活动"); } // 检查购买数量限制 @@ -174,6 +174,14 @@ public class FlashSaleService { } try { + // 二次校验:锁内重新检查用户是否已参与(防止并发竞态) + if (redisService.sIsMember(successUsersKey, userId)) { + return createFailResult("您已经参与过该秒杀活动"); + } + if (orderRepository.existsByUserIdAndFlashSaleIdAndOrderType(userId, flashSale.getId(), 2)) { + return createFailResult("您已经参与过该秒杀活动"); + } + // 检查并修复库存数据 String stockKey = FLASH_SALE_STOCK_PREFIX + flashSale.getId(); String currentStock = redisService.getString(stockKey); @@ -300,9 +308,11 @@ public class FlashSaleService { // 验证排序字段 String sortBy = validateSortField(queryDTO.getSortBy()); + // 限制分页大小 + int pageSize = Math.min(queryDTO.getSize(), 100); // 构建分页和排序 Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), sortBy); - Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort); + Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort); Page flashSalePage; LocalDateTime now = LocalDateTime.now(); @@ -621,6 +631,55 @@ public class FlashSaleService { } } + /** + * 恢复秒杀库存(订单取消时调用) + * 恢复Redis库存、DB库存,并移除成功用户集合记录 + */ + @Transactional + public void restoreFlashSaleStock(Long flashSaleId, Long productId, LocalDateTime orderCreatedAt, Long userId, + Integer quantity) { + log.info("恢复秒杀库存: flashSaleId={}, productId={}, orderCreatedAt={}, userId={}, quantity={}", + flashSaleId, productId, orderCreatedAt, userId, quantity); + + Optional flashSaleOpt = Optional.empty(); + if (flashSaleId == null) { + List matchedFlashSales = flashSaleRepository.findByProductIdAndCoveringTime(productId, + orderCreatedAt); + if (matchedFlashSales.size() == 1) { + flashSaleOpt = Optional.of(matchedFlashSales.get(0)); + log.info("根据商品和下单时间回填秒杀活动: flashSaleId={}, productId={}", + flashSaleOpt.get().getId(), productId); + } else { + log.warn("订单未记录秒杀活动ID且无法唯一匹配历史活动,跳过秒杀库存恢复: productId={}, matches={}", + productId, matchedFlashSales.size()); + return; + } + } else { + flashSaleOpt = flashSaleRepository.findById(flashSaleId); + } + + if (!flashSaleOpt.isPresent()) { + log.warn("未找到对应的秒杀活动,跳过秒杀库存恢复: flashSaleId={}", flashSaleId); + return; + } + + FlashSale flashSale = flashSaleOpt.get(); + + // 恢复Redis库存 + String stockKey = FLASH_SALE_STOCK_PREFIX + flashSale.getId(); + redisService.incrBy(stockKey, quantity); + + // 恢复DB库存 + flashSaleRepository.increaseFlashStock(flashSale.getId(), quantity); + + // 移除成功用户集合记录 + String successUsersKey = FLASH_SALE_SUCCESS_USERS_PREFIX + flashSale.getId(); + redisService.sRem(successUsersKey, userId); + + log.info("秒杀库存恢复成功: flashSaleId={}, userId={}, quantity={}", + flashSale.getId(), userId, quantity); + } + /** * 获取秒杀活动剩余库存 */ @@ -712,7 +771,7 @@ public class FlashSaleService { } // 检查是否有相关订单 - if (orderRepository.existsFlashSaleOrder(null, flashSale.getProductId())) { + if (orderRepository.existsByFlashSaleIdAndOrderType(flashSaleId, 2)) { throw new RuntimeException("该秒杀活动已有订单,无法删除"); } @@ -886,6 +945,60 @@ public class FlashSaleService { return buildFlashSaleDTO(flashSale, product); } + /** + * 获取秒杀活动统计信息(即将开始、正在进行的全局数量 + 用户参与/成功数量) + */ + public Map getFlashSaleStatistics(Long userId) { + LocalDateTime now = LocalDateTime.now(); + + long upcoming = flashSaleRepository.findUpcomingFlashSales(now).size(); + long active = flashSaleRepository.findActiveFlashSales(now).size(); + + long participated = 0; + long success = 0; + if (userId != null) { + participated = orderRepository.countByUserIdAndOrderType(userId, 2); + success = orderRepository.countByUserIdAndOrderTypeAndStatusNot5(userId, 2); + } + + Map stats = new HashMap<>(); + stats.put("upcoming", upcoming); + stats.put("active", active); + stats.put("participated", participated); + stats.put("success", success); + return stats; + } + + /** + * 定时预热即将开始的秒杀活动库存(每5分钟执行一次) + */ + @Scheduled(fixedRate = 300000) + public void scheduledPreloadFlashSales() { + log.info("定时任务:检查即将开始的秒杀活动并预热库存"); + try { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime threshold = now.plusMinutes(30); + List upcomingFlashSales = flashSaleRepository.findUpcomingFlashSales(now); + + int preloadCount = 0; + for (FlashSale flashSale : upcomingFlashSales) { + if (flashSale.getStartTime().isBefore(threshold)) { + preloadFlashSale(flashSale.getId()); + preloadCount++; + } + } + + if (preloadCount > 0) { + log.info("定时预热完成:预热了{}个即将开始的秒杀活动", preloadCount); + } + + // 同时更新秒杀活动状态 + updateFlashSaleStatus(); + } catch (Exception e) { + log.error("定时预热秒杀活动失败", e); + } + } + /** * 更新秒杀活动状态 */ @@ -1051,6 +1164,7 @@ public class FlashSaleService { order.setOrderNo("FS" + System.currentTimeMillis() + String.format("%03d", new java.util.Random().nextInt(1000))); order.setUserId(userId); order.setProductId(flashSale.getProductId()); + order.setFlashSaleId(flashSale.getId()); order.setQuantity(participateDTO.getQuantity()); order.setTotalPrice(flashSale.getFlashPrice().multiply(BigDecimal.valueOf(participateDTO.getQuantity()))); order.setStatus(1); // 待支付 diff --git a/src/main/java/com/org/flashsalesystem/service/GroupBuyingService.java b/src/main/java/com/org/flashsalesystem/service/GroupBuyingService.java new file mode 100644 index 0000000..d9177f9 --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/service/GroupBuyingService.java @@ -0,0 +1,652 @@ +package com.org.flashsalesystem.service; + +import com.org.flashsalesystem.dto.GroupBuyingDTO; +import com.org.flashsalesystem.dto.ProductDTO; +import com.org.flashsalesystem.dto.UserDTO; +import com.org.flashsalesystem.entity.*; +import com.org.flashsalesystem.repository.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class GroupBuyingService { + + private static final String GB_STOCK_PREFIX = "groupbuying_stock:"; + private static final String GB_LOCK_PREFIX = "groupbuying_lock:"; + private static final String GB_MEMBERS_PREFIX = "groupbuying_members:"; + + @Autowired + private GroupBuyingRepository groupBuyingRepository; + + @Autowired + private GroupBuyingGroupRepository groupBuyingGroupRepository; + + @Autowired + private GroupBuyingMemberRepository groupBuyingMemberRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ProductService productService; + + @Autowired + private UserService userService; + + @Autowired + private RedisService redisService; + + @Autowired + private RedissonLockService redissonLockService; + + @Autowired + @Qualifier("customStringRedisTemplate") + private RedisTemplate stringRedisTemplate; + + @Autowired + private DefaultRedisScript groupBuyingStockScript; + + // ========== 管理员操作 ========== + + @Transactional + public GroupBuyingDTO createGroupBuying(GroupBuyingDTO.CreateDTO createDTO) { + log.info("创建拼团活动: productId={}", createDTO.getProductId()); + + Product product = productRepository.findById(createDTO.getProductId()) + .orElseThrow(() -> new RuntimeException("商品不存在")); + + if (createDTO.getGroupPrice().compareTo(product.getPrice()) >= 0) { + throw new RuntimeException("拼团价格必须低于商品原价"); + } + + if (createDTO.getEndTime().isBefore(createDTO.getStartTime())) { + throw new RuntimeException("结束时间不能早于开始时间"); + } + + GroupBuying gb = new GroupBuying(); + gb.setProductId(createDTO.getProductId()); + gb.setGroupPrice(createDTO.getGroupPrice()); + gb.setRequiredMembers(createDTO.getRequiredMembers()); + gb.setDurationMinutes(createDTO.getDurationMinutes()); + gb.setTotalStock(createDTO.getTotalStock()); + gb.setRemainingStock(createDTO.getTotalStock()); + gb.setMaxPerUser(createDTO.getMaxPerUser()); + gb.setStatus(0); // 草稿 + gb.setStartTime(createDTO.getStartTime()); + gb.setEndTime(createDTO.getEndTime()); + + gb = groupBuyingRepository.save(gb); + log.info("拼团活动创建成功: id={}", gb.getId()); + + return buildDTO(gb); + } + + @Transactional + public GroupBuyingDTO updateGroupBuying(Long id, GroupBuyingDTO.UpdateDTO updateDTO) { + GroupBuying gb = groupBuyingRepository.findById(id) + .orElseThrow(() -> new RuntimeException("拼团活动不存在")); + + if (gb.getStatus() == 2) { + throw new RuntimeException("进行中的活动不能修改"); + } + + if (updateDTO.getProductId() != null) gb.setProductId(updateDTO.getProductId()); + if (updateDTO.getGroupPrice() != null) gb.setGroupPrice(updateDTO.getGroupPrice()); + if (updateDTO.getRequiredMembers() != null) gb.setRequiredMembers(updateDTO.getRequiredMembers()); + if (updateDTO.getDurationMinutes() != null) gb.setDurationMinutes(updateDTO.getDurationMinutes()); + if (updateDTO.getTotalStock() != null) { + int diff = updateDTO.getTotalStock() - gb.getTotalStock(); + gb.setTotalStock(updateDTO.getTotalStock()); + gb.setRemainingStock(gb.getRemainingStock() + diff); + } + if (updateDTO.getMaxPerUser() != null) gb.setMaxPerUser(updateDTO.getMaxPerUser()); + if (updateDTO.getStatus() != null) gb.setStatus(updateDTO.getStatus()); + if (updateDTO.getStartTime() != null) gb.setStartTime(updateDTO.getStartTime()); + if (updateDTO.getEndTime() != null) gb.setEndTime(updateDTO.getEndTime()); + + gb = groupBuyingRepository.save(gb); + return buildDTO(gb); + } + + @Transactional + public boolean deleteGroupBuying(Long id) { + GroupBuying gb = groupBuyingRepository.findById(id) + .orElseThrow(() -> new RuntimeException("拼团活动不存在")); + + if (gb.getStatus() == 2) { + throw new RuntimeException("进行中的活动不能删除"); + } + + groupBuyingRepository.deleteById(id); + redisService.delete(GB_STOCK_PREFIX + id); + return true; + } + + // ========== 查询操作 ========== + + public Map getGroupBuyingList(int page, int size, Integer status) { + int pageSize = Math.min(size, 100); + Pageable pageable = PageRequest.of(page, pageSize, Sort.by(Sort.Direction.DESC, "createdAt")); + + Page gbPage; + LocalDateTime now = LocalDateTime.now(); + + if (status != null) { + if (status == 2) { + gbPage = groupBuyingRepository.findActiveGroupBuyings(now, pageable); + } else if (status == 3) { + gbPage = groupBuyingRepository.findEndedGroupBuyings(now, pageable); + } else { + gbPage = groupBuyingRepository.findByStatus(status, pageable); + } + } else { + gbPage = groupBuyingRepository.findAll(pageable); + } + + List dtos = gbPage.getContent().stream() + .map(this::buildDTO) + .collect(Collectors.toList()); + + Map result = new HashMap<>(); + result.put("content", dtos); + result.put("totalElements", gbPage.getTotalElements()); + result.put("totalPages", gbPage.getTotalPages()); + result.put("currentPage", gbPage.getNumber()); + result.put("size", gbPage.getSize()); + return result; + } + + public GroupBuyingDTO getGroupBuyingDetail(Long id) { + GroupBuying gb = groupBuyingRepository.findById(id) + .orElseThrow(() -> new RuntimeException("拼团活动不存在")); + return buildDTO(gb); + } + + public Map getGroupsByActivity(Long groupBuyingId, int page, int size) { + Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt")); + Page groupPage = groupBuyingGroupRepository.findByGroupBuyingId(groupBuyingId, pageable); + + List dtos = groupPage.getContent().stream() + .map(this::buildGroupInfoDTO) + .collect(Collectors.toList()); + + Map result = new HashMap<>(); + result.put("content", dtos); + result.put("totalElements", groupPage.getTotalElements()); + result.put("totalPages", groupPage.getTotalPages()); + result.put("currentPage", groupPage.getNumber()); + result.put("size", groupPage.getSize()); + return result; + } + + public GroupBuyingDTO.GroupInfoDTO getGroupDetail(Long groupId) { + GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId) + .orElseThrow(() -> new RuntimeException("团组不存在")); + return buildGroupInfoDTO(group); + } + + public Map getMyGroups(Long userId, int page, int size) { + Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt")); + Page groupPage = groupBuyingGroupRepository.findByMemberUserId(userId, pageable); + + List dtos = groupPage.getContent().stream() + .map(this::buildGroupInfoDTO) + .collect(Collectors.toList()); + + Map result = new HashMap<>(); + result.put("content", dtos); + result.put("totalElements", groupPage.getTotalElements()); + result.put("totalPages", groupPage.getTotalPages()); + result.put("currentPage", groupPage.getNumber()); + result.put("size", groupPage.getSize()); + return result; + } + + public GroupBuyingDTO.StatisticsDTO getStatistics(Long userId) { + GroupBuyingDTO.StatisticsDTO stats = new GroupBuyingDTO.StatisticsDTO(); + LocalDateTime now = LocalDateTime.now(); + + stats.setTotalActivities(groupBuyingRepository.count()); + stats.setActiveActivities(groupBuyingRepository.countByStatus(2)); + + if (userId != null) { + Page myGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1)); + stats.setMyGroups(myGroups.getTotalElements()); + + // Count successful groups where user participated + long successCount = 0; + BigDecimal totalSaved = BigDecimal.ZERO; + Page allMyGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1000)); + for (GroupBuyingGroup g : allMyGroups.getContent()) { + if (g.getStatus() == 2) { + successCount++; + GroupBuying gb = groupBuyingRepository.findById(g.getGroupBuyingId()).orElse(null); + if (gb != null) { + Product product = productRepository.findById(gb.getProductId()).orElse(null); + if (product != null) { + totalSaved = totalSaved.add(product.getPrice().subtract(gb.getGroupPrice())); + } + } + } + } + stats.setSuccessGroups(successCount); + stats.setTotalSaved(totalSaved); + } else { + stats.setMyGroups(0L); + stats.setSuccessGroups(0L); + stats.setTotalSaved(BigDecimal.ZERO); + } + + return stats; + } + + // ========== 核心拼团逻辑 ========== + + @Transactional + public GroupBuyingDTO.JoinResultDTO joinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) { + Long activityId = joinDTO.getGroupBuyingId(); + String lockKey = GB_LOCK_PREFIX + activityId; + + if (!redissonLockService.tryLock(lockKey, 5, 30)) { + throw new RuntimeException("系统繁忙,请稍后重试"); + } + + try { + return doJoinGroupBuying(joinDTO, userId); + } finally { + redissonLockService.unlock(lockKey); + } + } + + private GroupBuyingDTO.JoinResultDTO doJoinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) { + Long activityId = joinDTO.getGroupBuyingId(); + + // 1. 校验活动状态 + GroupBuying gb = groupBuyingRepository.findById(activityId) + .orElseThrow(() -> new RuntimeException("拼团活动不存在")); + + if (!gb.isActive()) { + throw new RuntimeException("拼团活动未在进行中"); + } + + if (gb.getRemainingStock() <= 0) { + throw new RuntimeException("库存不足"); + } + + // 2. 获取商品信息 + Product product = productRepository.findById(gb.getProductId()) + .orElseThrow(() -> new RuntimeException("商品不存在")); + + GroupBuyingGroup group; + + if (joinDTO.getGroupId() != null) { + // 加入现有团组 + group = groupBuyingGroupRepository.findById(joinDTO.getGroupId()) + .orElseThrow(() -> new RuntimeException("团组不存在")); + + if (group.getStatus() != 1) { + throw new RuntimeException("该团组已成团或已过期"); + } + + if (LocalDateTime.now().isAfter(group.getExpireTime())) { + throw new RuntimeException("该团组已过期"); + } + + if (group.getCurrentMembers() >= group.getRequiredMembers()) { + throw new RuntimeException("该团组已满员"); + } + + // 检查用户是否已经在该团组中 + if (groupBuyingMemberRepository.existsByGroupIdAndUserIdAndStatusNot(group.getId(), userId, 3)) { + throw new RuntimeException("您已在该团组中"); + } + } else { + // 创建新团组 + group = new GroupBuyingGroup(); + group.setGroupNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000))); + group.setGroupBuyingId(activityId); + group.setLeaderUserId(userId); + group.setRequiredMembers(gb.getRequiredMembers()); + group.setCurrentMembers(0); // will be incremented below + group.setStatus(1); + group.setExpireTime(LocalDateTime.now().plusMinutes(gb.getDurationMinutes())); + group = groupBuyingGroupRepository.save(group); + } + + // 3. Redis 原子扣库存 + String stockKey = GB_STOCK_PREFIX + activityId; + Long result = stringRedisTemplate.execute(groupBuyingStockScript, + Collections.singletonList(stockKey), "1"); + + if (result == null || result < 0) { + // Redis stock exhausted, double check DB + if (gb.getRemainingStock() <= 0) { + throw new RuntimeException("库存不足"); + } + } + + // 4. DB 扣库存 + int updated = groupBuyingRepository.updateStock(activityId, 1); + if (updated == 0) { + // Restore Redis stock + redisService.incrBy(stockKey, 1); + throw new RuntimeException("库存不足"); + } + + // 5. 创建订单 + Order order = new Order(); + order.setOrderNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000))); + order.setUserId(userId); + order.setProductId(gb.getProductId()); + order.setQuantity(1); + order.setTotalPrice(gb.getGroupPrice()); + order.setStatus(1); // 待支付 + order.setOrderType(3); // 拼团订单 + order.setGroupBuyingGroupId(group.getId()); + order = orderRepository.save(order); + + // 6. 创建成员记录 + GroupBuyingMember member = new GroupBuyingMember(); + member.setGroupId(group.getId()); + member.setUserId(userId); + member.setOrderId(order.getId()); + member.setStatus(1); + groupBuyingMemberRepository.save(member); + + // 7. 更新团组人数 + groupBuyingGroupRepository.incrementCurrentMembers(group.getId()); + + // Refresh group from DB + group = groupBuyingGroupRepository.findById(group.getId()).orElse(group); + + // 8. 检查是否满员 → 成团 + if (group.getCurrentMembers() >= group.getRequiredMembers()) { + groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 2, LocalDateTime.now()); + // Update all members status to SUCCESS + groupBuyingMemberRepository.updateStatusByGroupId(group.getId(), 2); + log.info("拼团成功: groupId={}, groupNo={}", group.getId(), group.getGroupNo()); + } + + // Add to Redis members set + redisService.sAdd(GB_MEMBERS_PREFIX + group.getId(), userId.toString()); + redisService.expire(GB_MEMBERS_PREFIX + group.getId(), 7, TimeUnit.DAYS); + + GroupBuyingDTO.JoinResultDTO resultDTO = new GroupBuyingDTO.JoinResultDTO(); + resultDTO.setSuccess(true); + resultDTO.setMessage(joinDTO.getGroupId() != null ? "加入拼团成功" : "开团成功"); + resultDTO.setGroupId(group.getId()); + resultDTO.setGroupNo(group.getGroupNo()); + resultDTO.setOrderId(order.getId()); + + log.info("用户{}参与拼团成功: activityId={}, groupId={}, orderId={}", userId, activityId, group.getId(), order.getId()); + return resultDTO; + } + + @Transactional + public void cancelMembership(Long groupId, Long userId) { + log.info("退出拼团: groupId={}, userId={}", groupId, userId); + + GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId) + .orElseThrow(() -> new RuntimeException("团组不存在")); + + if (group.getStatus() != 1) { + throw new RuntimeException("已成团或已失败的团组不能退出"); + } + + GroupBuyingMember member = groupBuyingMemberRepository.findByGroupIdAndUserId(groupId, userId) + .orElseThrow(() -> new RuntimeException("您不在该团组中")); + + if (member.getStatus() == 3) { + throw new RuntimeException("您已退出该团组"); + } + + // 更新成员状态 + member.setStatus(3); + groupBuyingMemberRepository.save(member); + + // 更新团组人数 + groupBuyingGroupRepository.decrementCurrentMembers(groupId); + + // 恢复库存 + Long activityId = group.getGroupBuyingId(); + groupBuyingRepository.increaseStock(activityId, 1); + redisService.incrBy(GB_STOCK_PREFIX + activityId, 1); + + // 取消订单 + if (member.getOrderId() != null) { + Optional orderOpt = orderRepository.findById(member.getOrderId()); + if (orderOpt.isPresent()) { + Order order = orderOpt.get(); + if (order.getStatus() == 1) { + order.setStatus(5); + order.setRemark("退出拼团,订单取消"); + orderRepository.save(order); + } + } + } + + // Remove from Redis set + redisService.sRem(GB_MEMBERS_PREFIX + groupId, userId.toString()); + + log.info("退出拼团成功: groupId={}, userId={}", groupId, userId); + } + + // ========== 定时任务 ========== + + @Scheduled(fixedRate = 60000) // 每分钟检查 + @Transactional + public void scheduledCheckExpiredGroups() { + List expiredGroups = groupBuyingGroupRepository.findExpiredGroups(LocalDateTime.now()); + for (GroupBuyingGroup group : expiredGroups) { + try { + handleExpiredGroup(group); + } catch (Exception e) { + log.error("处理超时团组失败: groupId={}", group.getId(), e); + } + } + } + + @Transactional + public void handleExpiredGroup(GroupBuyingGroup group) { + log.info("处理超时团组: groupId={}, groupNo={}", group.getId(), group.getGroupNo()); + + // Mark group as FAILED + groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 3, LocalDateTime.now()); + + // Get active members + List members = groupBuyingMemberRepository.findByGroupIdAndStatus(group.getId(), 1); + + for (GroupBuyingMember member : members) { + // Update member status + member.setStatus(3); + groupBuyingMemberRepository.save(member); + + // Restore stock + groupBuyingRepository.increaseStock(group.getGroupBuyingId(), 1); + redisService.incrBy(GB_STOCK_PREFIX + group.getGroupBuyingId(), 1); + + // Cancel order + if (member.getOrderId() != null) { + Optional orderOpt = orderRepository.findById(member.getOrderId()); + if (orderOpt.isPresent()) { + Order order = orderOpt.get(); + if (order.getStatus() == 1) { + order.setStatus(5); + order.setRemark("拼团超时,订单自动取消"); + orderRepository.save(order); + } + } + } + } + + // Clean Redis + redisService.delete(GB_MEMBERS_PREFIX + group.getId()); + + log.info("超时团组处理完成: groupId={}", group.getId()); + } + + @Scheduled(fixedRate = 300000) // 每5分钟 + @Transactional + public void scheduledUpdateStatus() { + LocalDateTime now = LocalDateTime.now(); + + // Activate pending activities whose start time has passed + List upcoming = groupBuyingRepository.findUpcomingGroupBuyings(now); + // These are status=1 and startTime > now, so nothing to activate here + + // Actually find activities that should be active: status=1, startTime <= now, endTime > now + List all = groupBuyingRepository.findAll(); + for (GroupBuying gb : all) { + if (gb.getStatus() == 1 && !now.isBefore(gb.getStartTime()) && now.isBefore(gb.getEndTime())) { + groupBuyingRepository.updateStatus(gb.getId(), 2); + preloadStock(gb); + log.info("拼团活动已激活: id={}", gb.getId()); + } else if (gb.getStatus() == 2 && !now.isBefore(gb.getEndTime())) { + groupBuyingRepository.updateStatus(gb.getId(), 3); + log.info("拼团活动已结束: id={}", gb.getId()); + } + } + } + + public void preloadStock(GroupBuying gb) { + String stockKey = GB_STOCK_PREFIX + gb.getId(); + redisService.setString(stockKey, String.valueOf(gb.getRemainingStock())); + long ttl = java.time.Duration.between(LocalDateTime.now(), gb.getEndTime()).getSeconds() + 3600; + redisService.expire(stockKey, ttl, TimeUnit.SECONDS); + log.info("拼团库存预热: id={}, stock={}", gb.getId(), gb.getRemainingStock()); + } + + public void preloadAllActiveStock() { + List activeList = groupBuyingRepository.findActiveGroupBuyings(LocalDateTime.now()); + for (GroupBuying gb : activeList) { + preloadStock(gb); + } + log.info("所有拼团活动库存预热完成, count={}", activeList.size()); + } + + // ========== 构建 DTO ========== + + private GroupBuyingDTO buildDTO(GroupBuying gb) { + GroupBuyingDTO dto = new GroupBuyingDTO(); + dto.setId(gb.getId()); + dto.setProductId(gb.getProductId()); + dto.setGroupPrice(gb.getGroupPrice()); + dto.setRequiredMembers(gb.getRequiredMembers()); + dto.setDurationMinutes(gb.getDurationMinutes()); + dto.setTotalStock(gb.getTotalStock()); + dto.setRemainingStock(gb.getRemainingStock()); + dto.setMaxPerUser(gb.getMaxPerUser()); + dto.setStatus(gb.getStatus()); + dto.setStatusDescription(getStatusDescription(gb.getStatus())); + dto.setStartTime(gb.getStartTime()); + dto.setEndTime(gb.getEndTime()); + dto.setCreatedAt(gb.getCreatedAt()); + dto.setUpdatedAt(gb.getUpdatedAt()); + + // Product info + ProductDTO product = productService.getProductById(gb.getProductId()); + if (product != null) { + dto.setProductName(product.getName()); + dto.setProductImageUrl(product.getImageUrl()); + dto.setProductPrice(product.getPrice()); + dto.setDiscount(product.getPrice().subtract(gb.getGroupPrice())); + } + + // Active group count + long activeGroups = groupBuyingGroupRepository.countByGroupBuyingIdAndStatus(gb.getId(), 1); + dto.setActiveGroupCount((int) activeGroups); + + return dto; + } + + private GroupBuyingDTO.GroupInfoDTO buildGroupInfoDTO(GroupBuyingGroup group) { + GroupBuyingDTO.GroupInfoDTO dto = new GroupBuyingDTO.GroupInfoDTO(); + dto.setId(group.getId()); + dto.setGroupNo(group.getGroupNo()); + dto.setGroupBuyingId(group.getGroupBuyingId()); + dto.setLeaderUserId(group.getLeaderUserId()); + dto.setRequiredMembers(group.getRequiredMembers()); + dto.setCurrentMembers(group.getCurrentMembers()); + dto.setStatus(group.getStatus()); + dto.setStatusDescription(getGroupStatusDescription(group.getStatus())); + dto.setExpireTime(group.getExpireTime()); + dto.setCreatedAt(group.getCreatedAt()); + dto.setCompletedAt(group.getCompletedAt()); + + // Leader info + UserDTO leader = userService.getUserById(group.getLeaderUserId()); + if (leader != null) { + dto.setLeaderUsername(leader.getUsername()); + } + + // Members + List members = groupBuyingMemberRepository.findByGroupId(group.getId()); + List memberDTOs = members.stream() + .filter(m -> m.getStatus() != 3) // exclude exited + .map(m -> { + GroupBuyingDTO.MemberDTO memberDTO = new GroupBuyingDTO.MemberDTO(); + memberDTO.setId(m.getId()); + memberDTO.setUserId(m.getUserId()); + memberDTO.setOrderId(m.getOrderId()); + memberDTO.setStatus(m.getStatus()); + memberDTO.setJoinedAt(m.getJoinedAt()); + UserDTO user = userService.getUserById(m.getUserId()); + if (user != null) { + memberDTO.setUsername(user.getUsername()); + memberDTO.setAvatar(user.getAvatar()); + } + return memberDTO; + }) + .collect(Collectors.toList()); + dto.setMembers(memberDTOs); + + // Activity info + GroupBuying gb = groupBuyingRepository.findById(group.getGroupBuyingId()).orElse(null); + if (gb != null) { + dto.setGroupBuying(buildDTO(gb)); + } + + return dto; + } + + private String getStatusDescription(Integer status) { + if (status == null) return "未知"; + switch (status) { + case 0: return "草稿"; + case 1: return "未开始"; + case 2: return "进行中"; + case 3: return "已结束"; + default: return "未知"; + } + } + + private String getGroupStatusDescription(Integer status) { + if (status == null) return "未知"; + switch (status) { + case 1: return "拼团中"; + case 2: return "已成团"; + case 3: return "已失败"; + default: return "未知"; + } + } +} diff --git a/src/main/java/com/org/flashsalesystem/service/MessageListenerService.java b/src/main/java/com/org/flashsalesystem/service/MessageListenerService.java index ef2edb6..8d08c92 100644 --- a/src/main/java/com/org/flashsalesystem/service/MessageListenerService.java +++ b/src/main/java/com/org/flashsalesystem/service/MessageListenerService.java @@ -26,6 +26,9 @@ public class MessageListenerService { @Autowired private ObjectMapper objectMapper; + @Autowired + private NotificationService notificationService; + /** * 初始化消息监听器 */ @@ -62,31 +65,43 @@ public class MessageListenerService { * 处理订单状态变更 */ private void handleOrderStatusChange(Long orderId, Long userId, Integer status, String action) { - // 可以在这里实现: - // 1. 发送邮件通知 - // 2. 推送消息 - // 3. 更新统计数据 - // 4. 触发其他业务流程 + if (userId == null) { + log.warn("订单状态变更缺少用户ID: orderId={}", orderId); + return; + } + + String title; + String message; + String link = "/order/" + orderId; switch (action) { case "created": - log.info("订单创建通知处理: 订单ID={}", orderId); + title = "订单创建成功"; + message = "您的订单 #" + orderId + " 已创建,请尽快完成支付"; break; case "paid": - log.info("订单支付通知处理: 订单ID={}", orderId); + title = "订单支付成功"; + message = "您的订单 #" + orderId + " 已支付成功,等待商家发货"; break; case "shipped": - log.info("订单发货通知处理: 订单ID={}", orderId); + title = "订单已发货"; + message = "您的订单 #" + orderId + " 已发货,请注意查收"; break; case "completed": - log.info("订单完成通知处理: 订单ID={}", orderId); + title = "订单已完成"; + message = "您的订单 #" + orderId + " 已完成,欢迎评价"; break; case "cancelled": - log.info("订单取消通知处理: 订单ID={}", orderId); + title = "订单已取消"; + message = "您的订单 #" + orderId + " 已取消"; break; default: log.info("未知订单状态变更: {}", action); + return; } + + notificationService.createNotification(userId, "order", title, message, link); + log.info("订单状态变更通知已创建: 订单ID={}, 操作={}", orderId, action); } /** @@ -112,20 +127,23 @@ public class MessageListenerService { * 处理秒杀结果 */ private void handleFlashSaleResult(Long userId, Long flashSaleId, Boolean success, Map data) { - // 可以在这里实现: - // 1. 实时通知用户 - // 2. 统计秒杀数据 - // 3. 风控分析 - // 4. 营销推荐 + if (userId == null) { + log.warn("秒杀结果缺少用户ID: flashSaleId={}", flashSaleId); + return; + } + + String link = "/flashsale/" + flashSaleId; if (success) { - log.info("秒杀成功处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId); - // 发送成功通知 - sendFlashSaleSuccessNotification(userId, flashSaleId); + String title = "秒杀成功"; + String message = "恭喜您成功抢购秒杀商品,请尽快完成支付!"; + notificationService.createNotification(userId, "flashsale", title, message, link); + log.info("秒杀成功通知已创建: 用户ID={}, 秒杀ID={}", userId, flashSaleId); } else { - log.info("秒杀失败处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId); - // 可以推荐其他商品 - recommendAlternativeProducts(userId, flashSaleId); + String title = "秒杀未中"; + String message = "很遗憾,本次秒杀未能抢购成功,下次再来吧!"; + notificationService.createNotification(userId, "flashsale", title, message, link); + log.info("秒杀失败通知已创建: 用户ID={}, 秒杀ID={}", userId, flashSaleId); } } @@ -161,26 +179,9 @@ public class MessageListenerService { * 检查库存预警 */ private void checkStockAlert(Long productId) { - // 实现库存预警逻辑 log.debug("检查商品库存预警: 商品ID={}", productId); } - /** - * 发送秒杀成功通知 - */ - private void sendFlashSaleSuccessNotification(Long userId, Long flashSaleId) { - // 实现成功通知逻辑 - log.debug("发送秒杀成功通知: 用户ID={}, 秒杀ID={}", userId, flashSaleId); - } - - /** - * 推荐替代商品 - */ - private void recommendAlternativeProducts(Long userId, Long flashSaleId) { - // 实现商品推荐逻辑 - log.debug("推荐替代商品: 用户ID={}, 秒杀ID={}", userId, flashSaleId); - } - /** * 提取Long值 */ diff --git a/src/main/java/com/org/flashsalesystem/service/NotificationService.java b/src/main/java/com/org/flashsalesystem/service/NotificationService.java new file mode 100644 index 0000000..f8c2a0a --- /dev/null +++ b/src/main/java/com/org/flashsalesystem/service/NotificationService.java @@ -0,0 +1,57 @@ +package com.org.flashsalesystem.service; + +import com.org.flashsalesystem.entity.Notification; +import com.org.flashsalesystem.repository.NotificationRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Slf4j +public class NotificationService { + + @Autowired + private NotificationRepository notificationRepository; + + public void createNotification(Long userId, String type, String title, String message, String link) { + Notification notification = new Notification(); + notification.setUserId(userId); + notification.setType(type); + notification.setTitle(title); + notification.setMessage(message); + notification.setLink(link); + notification.setRead(false); + notificationRepository.save(notification); + log.debug("通知已创建: userId={}, type={}, title={}", userId, type, title); + } + + public List getUserNotifications(Long userId) { + return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + public List getUserNotificationsByType(Long userId, String type) { + return notificationRepository.findByUserIdAndTypeOrderByCreatedAtDesc(userId, type); + } + + public long getUnreadCount(Long userId) { + return notificationRepository.countByUserIdAndReadFalse(userId); + } + + @Transactional + public void markAsRead(Long notificationId, Long userId) { + notificationRepository.markAsRead(notificationId, userId); + } + + @Transactional + public void markAllAsRead(Long userId) { + notificationRepository.markAllAsRead(userId); + } + + @Transactional + public void clearAll(Long userId) { + notificationRepository.deleteByUserId(userId); + } +} diff --git a/src/main/java/com/org/flashsalesystem/service/OrderService.java b/src/main/java/com/org/flashsalesystem/service/OrderService.java index cb5b6d5..01923de 100644 --- a/src/main/java/com/org/flashsalesystem/service/OrderService.java +++ b/src/main/java/com/org/flashsalesystem/service/OrderService.java @@ -52,6 +52,10 @@ public class OrderService { private UserService userService; @Autowired private UserAddressRepository userAddressRepository; + @Autowired + private FlashSaleService flashSaleService; + @Autowired + private GroupBuyingService groupBuyingService; /** * 创建普通订单 @@ -219,14 +223,20 @@ public class OrderService { * 获取用户订单列表 */ public Map getUserOrders(Long userId, OrderDTO.QueryDTO queryDTO) { + // 限制分页大小 + int pageSize = Math.min(queryDTO.getSize(), 100); // 构建分页和排序 Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy()); - Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort); + Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort); Page orderPage; // 根据查询条件获取订单 - if (queryDTO.getStatus() != null) { + if (queryDTO.getStatus() != null && queryDTO.getOrderType() != null) { + orderPage = orderRepository.findByUserIdAndStatus(userId, queryDTO.getStatus(), pageable); + } else if (queryDTO.getOrderType() != null) { + orderPage = orderRepository.findByUserIdAndOrderType(userId, queryDTO.getOrderType(), pageable); + } else if (queryDTO.getStatus() != null) { orderPage = orderRepository.findByUserIdAndStatus(userId, queryDTO.getStatus(), pageable); } else { orderPage = orderRepository.findByUserId(userId, pageable); @@ -259,9 +269,11 @@ public class OrderService { * 获取所有订单列表(管理员) */ public Map getAllOrders(OrderDTO.QueryDTO queryDTO) { + // 限制分页大小 + int pageSize = Math.min(queryDTO.getSize(), 100); // 构建分页和排序 Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy()); - Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort); + Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort); Page orderPage; @@ -402,6 +414,21 @@ public class OrderService { // 恢复库存 productService.updateStock(order.getProductId(), order.getQuantity(), "increase"); + // 秒杀订单额外恢复秒杀库存 + if (order.getOrderType() != null && order.getOrderType() == 2) { + flashSaleService.restoreFlashSaleStock(order.getFlashSaleId(), order.getProductId(), order.getCreatedAt(), + order.getUserId(), order.getQuantity()); + } + + // 拼团订单额外处理 + if (order.getOrderType() != null && order.getOrderType() == 3 && order.getGroupBuyingGroupId() != null) { + try { + groupBuyingService.cancelMembership(order.getGroupBuyingGroupId(), order.getUserId()); + } catch (Exception e) { + log.warn("拼团退出处理失败: orderId={}, error={}", orderId, e.getMessage()); + } + } + // 更新缓存 cacheOrderInfo(order); @@ -569,6 +596,7 @@ public class OrderService { orderMap.put("groupNo", order.getGroupNo() == null ? "" : order.getGroupNo()); orderMap.put("userId", order.getUserId().toString()); orderMap.put("productId", order.getProductId().toString()); + orderMap.put("flashSaleId", order.getFlashSaleId() == null ? "" : order.getFlashSaleId().toString()); orderMap.put("quantity", order.getQuantity().toString()); orderMap.put("totalPrice", order.getTotalPrice().toString()); orderMap.put("status", order.getStatus().toString()); @@ -603,6 +631,8 @@ public class OrderService { orderDTO.setGroupNo((String) orderMap.get("groupNo")); orderDTO.setUserId(Long.valueOf((String) orderMap.get("userId"))); orderDTO.setProductId(Long.valueOf((String) orderMap.get("productId"))); + String flashSaleId = (String) orderMap.get("flashSaleId"); + if (flashSaleId != null && !flashSaleId.isEmpty()) { orderDTO.setFlashSaleId(Long.valueOf(flashSaleId)); } orderDTO.setQuantity(Integer.valueOf((String) orderMap.get("quantity"))); orderDTO.setTotalPrice(new BigDecimal((String) orderMap.get("totalPrice"))); orderDTO.setStatus(Integer.valueOf((String) orderMap.get("status"))); @@ -849,6 +879,8 @@ public class OrderService { return "普通订单"; case 2: return "秒杀订单"; + case 3: + return "拼团订单"; default: return "未知类型"; } diff --git a/src/main/java/com/org/flashsalesystem/service/ProductReviewService.java b/src/main/java/com/org/flashsalesystem/service/ProductReviewService.java index 24ae04d..03f8d12 100644 --- a/src/main/java/com/org/flashsalesystem/service/ProductReviewService.java +++ b/src/main/java/com/org/flashsalesystem/service/ProductReviewService.java @@ -4,9 +4,11 @@ import com.org.flashsalesystem.dto.ProductReviewDTO; import com.org.flashsalesystem.dto.UserDTO; import com.org.flashsalesystem.entity.Order; import com.org.flashsalesystem.entity.OrderItem; +import com.org.flashsalesystem.entity.Product; import com.org.flashsalesystem.entity.ProductReview; import com.org.flashsalesystem.repository.OrderItemRepository; import com.org.flashsalesystem.repository.OrderRepository; +import com.org.flashsalesystem.repository.ProductRepository; import com.org.flashsalesystem.repository.ProductReviewRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; @@ -15,6 +17,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -30,6 +33,9 @@ public class ProductReviewService { @Autowired private OrderItemRepository orderItemRepository; + @Autowired + private ProductRepository productRepository; + @Autowired private UserService userService; @@ -68,7 +74,7 @@ public class ProductReviewService { .map(this::toDTO) .collect(Collectors.toList()); Double average = productReviewRepository.findAverageRatingByProductId(productId); - Long total = productReviewRepository.countByProductId(productId); + Long total = productReviewRepository.countByProductIdAndStatus(productId, 1); return new ProductReviewDTO.SummaryDTO(average == null ? 0.0 : average, total, reviews); } @@ -89,12 +95,39 @@ public class ProductReviewService { return toDTO(review); } + public ProductReviewDTO.CheckDTO checkReviewStatus(Long orderId, Long productId) { + ProductReviewDTO.CheckDTO checkDTO = new ProductReviewDTO.CheckDTO(); + Optional review = productReviewRepository.findByOrderIdAndProductId(orderId, productId); + checkDTO.setReviewed(review.isPresent()); + review.ifPresent(r -> checkDTO.setReview(toDTO(r))); + return checkDTO; + } + + public List getUserReviews(Long userId) { + return productReviewRepository.findByUserIdOrderByCreatedAtDesc(userId) + .stream() + .map(this::toDTO) + .collect(Collectors.toList()); + } + + public List getOrderReviews(Long orderId) { + return productReviewRepository.findByOrderId(orderId) + .stream() + .map(this::toDTO) + .collect(Collectors.toList()); + } + private ProductReviewDTO toDTO(ProductReview review) { ProductReviewDTO dto = new ProductReviewDTO(); BeanUtils.copyProperties(review, dto); UserDTO user = userService.getUserById(review.getUserId()); dto.setUsername(user != null ? user.getUsername() : "匿名用户"); dto.setStatusText(review.getStatus() != null && review.getStatus() == 1 ? "显示" : "隐藏"); + Optional product = productRepository.findById(review.getProductId()); + if (product.isPresent()) { + dto.setProductName(product.get().getName()); + dto.setProductImage(product.get().getImageUrl()); + } return dto; } } diff --git a/src/main/java/com/org/flashsalesystem/service/ProductService.java b/src/main/java/com/org/flashsalesystem/service/ProductService.java index 3fc92a2..06530e9 100644 --- a/src/main/java/com/org/flashsalesystem/service/ProductService.java +++ b/src/main/java/com/org/flashsalesystem/service/ProductService.java @@ -120,9 +120,11 @@ public class ProductService { return (Map) cachedResult; } + // 限制分页大小 + int pageSize = Math.min(queryDTO.getSize(), 100); // 构建分页和排序 Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy()); - Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort); + Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort); Integer status = queryDTO.getStatus() != null ? queryDTO.getStatus() : 1; String keyword = queryDTO.getKeyword() != null && !queryDTO.getKeyword().trim().isEmpty() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f2a7326..1758d9d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,8 @@ server: port: 8080 servlet: context-path: / + session: + timeout: 30m spring: application: diff --git a/src/main/resources/lua/groupbuying_stock.lua b/src/main/resources/lua/groupbuying_stock.lua new file mode 100644 index 0000000..d7a8161 --- /dev/null +++ b/src/main/resources/lua/groupbuying_stock.lua @@ -0,0 +1,32 @@ +-- 拼团库存扣减Lua脚本 +-- 功能:原子性地检查库存并扣减,防止超卖 +-- 参数:KEYS[1] = 库存key, ARGV[1] = 扣减数量 +-- 返回值:成功返回剩余库存,失败返回负数 + +local stock_key = KEYS[1] +local quantity_str = ARGV[1] +local quantity = tonumber(quantity_str) + +if quantity == nil or quantity <= 0 then + return -3 +end + +local current_stock = redis.call('GET', stock_key) + +if current_stock == false then + return -1 +end + +local current_stock_num = tonumber(current_stock) + +if current_stock_num == nil then + return -1 +end + +if current_stock_num < quantity then + return -2 +end + +local remaining_stock = redis.call('DECRBY', stock_key, quantity) + +return remaining_stock diff --git a/src/main/resources/sql/demo-users.sql b/src/main/resources/sql/demo-users.sql index b1cc868..b002198 100644 --- a/src/main/resources/sql/demo-users.sql +++ b/src/main/resources/sql/demo-users.sql @@ -1,22 +1,17 @@ --- 演示账号快速创建脚本 --- 密码都是明文对应的值:demo1/demo2/admin的密码分别是123456/123456/admin123 +-- 演示账号初始化脚本 +-- 账号:demo1 / 123456,demo2 / 123456,admin / admin123 USE flash_sale_db; --- 插入演示用户(密码已加密) -INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at) -VALUES ('demo1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo1@example.com', '13800138001', 'USER', 1, - NOW(), NOW()), - ('demo2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo2@example.com', '13800138002', 'USER', 1, - NOW(), NOW()), - ('admin', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1, - NOW(), NOW()) -ON DUPLICATE KEY UPDATE username = VALUES(username), - email = VALUES(email), - phone = VALUES(phone), - updated_at = NOW(); - --- 验证插入结果 -SELECT id, username, email, phone, status, created_at -FROM users -WHERE username IN ('demo1', 'demo2', 'admin'); +INSERT INTO users (username, password, email, phone, avatar, role, status, created_at, updated_at) +VALUES + ('demo1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo1@example.com', '13800138001', '', 'USER', 1, NOW(), NOW()), + ('demo2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo2@example.com', '13800138002', '', 'USER', 1, NOW(), NOW()), + ('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', '', 'ADMIN', 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE + email = VALUES(email), + phone = VALUES(phone), + avatar = VALUES(avatar), + role = VALUES(role), + status = VALUES(status), + updated_at = NOW(); diff --git a/src/main/resources/sql/fix-demo-users.sql b/src/main/resources/sql/fix-demo-users.sql deleted file mode 100644 index 61d1b97..0000000 --- a/src/main/resources/sql/fix-demo-users.sql +++ /dev/null @@ -1,33 +0,0 @@ --- 修复演示账号密码问题 --- 使用正确的BCrypt加密密码 - -USE flash_sale_db; - --- 删除现有的演示用户(如果存在) -DELETE -FROM users -WHERE username IN ('demo1', 'demo2', 'admin'); - --- 插入正确的演示用户 --- demo1/demo2 密码: 123456 --- admin 密码: admin123 -INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at) -VALUES ('demo1', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo1@example.com', '13800138001', 'USER', 1, - NOW(), NOW()), - ('demo2', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo2@example.com', '13800138002', 'USER', 1, - NOW(), NOW()), - ('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1, - NOW(), NOW()); - --- 验证插入结果 -SELECT id, username, email, phone, status, created_at -FROM users -WHERE username IN ('demo1', 'demo2', 'admin'); - --- 显示密码提示 -SELECT '演示账号密码信息:' as info; -SELECT 'demo1 / 123456' as account_info -UNION ALL -SELECT 'demo2 / 123456' -UNION ALL -SELECT 'admin / admin123'; diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index d1fdcd7..ad32e98 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -1,31 +1,32 @@ --- 秒杀系统数据库表结构 --- 创建数据库和所有必要的表 +-- 秒杀系统数据库结构 +-- 说明:本脚本只负责数据库对象定义,不包含演示数据。 + +CREATE DATABASE IF NOT EXISTS flash_sale_db + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; --- 创建数据库 -CREATE DATABASE IF NOT EXISTS flash_sale_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE flash_sale_db; -- ================================ -- 1. 用户表 -- ================================ -CREATE TABLE IF NOT EXISTS users -( +CREATE TABLE IF NOT EXISTS users ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(255) NOT NULL COMMENT '密码(加密)', email VARCHAR(100) COMMENT '邮箱', phone VARCHAR(20) COMMENT '手机号', avatar VARCHAR(500) COMMENT '头像', - role VARCHAR(20) DEFAULT 'USER' COMMENT '角色:ADMIN/USER', - status TINYINT DEFAULT 1 COMMENT '状态:1-正常,0-禁用', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - - INDEX idx_username (username), - INDEX idx_email (email), - INDEX idx_phone (phone), - INDEX idx_status (status), - INDEX idx_created_at (created_at) + role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色:ADMIN/USER', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-禁用', + last_login TIMESTAMP NULL COMMENT '最后登录时间', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_users_username (username), + INDEX idx_users_email (email), + INDEX idx_users_phone (phone), + INDEX idx_users_status (status), + INDEX idx_users_created_at (created_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT ='用户表'; @@ -33,8 +34,7 @@ CREATE TABLE IF NOT EXISTS users -- ================================ -- 2. 商品表 -- ================================ -CREATE TABLE IF NOT EXISTS products -( +CREATE TABLE IF NOT EXISTS products ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID', name VARCHAR(200) NOT NULL COMMENT '商品名称', description TEXT COMMENT '商品描述', @@ -42,15 +42,15 @@ CREATE TABLE IF NOT EXISTS products category VARCHAR(100) COMMENT '商品分类', stock INT NOT NULL DEFAULT 0 COMMENT '库存数量', image_url VARCHAR(500) COMMENT '商品图片URL', - status TINYINT DEFAULT 1 COMMENT '状态:1-上架,0-下架', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - - INDEX idx_name (name), - INDEX idx_price (price), - INDEX idx_stock (stock), - INDEX idx_status (status), - INDEX idx_created_at (created_at) + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-上架,0-下架', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_products_name (name), + INDEX idx_products_category (category), + INDEX idx_products_price (price), + INDEX idx_products_stock (stock), + INDEX idx_products_status (status), + INDEX idx_products_created_at (created_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT ='商品表'; @@ -58,42 +58,41 @@ CREATE TABLE IF NOT EXISTS products -- ================================ -- 3. 秒杀活动表 -- ================================ -CREATE TABLE IF NOT EXISTS flash_sales -( +CREATE TABLE IF NOT EXISTS flash_sales ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '秒杀活动ID', product_id BIGINT NOT NULL COMMENT '商品ID', flash_price DECIMAL(10, 2) NOT NULL COMMENT '秒杀价格', flash_stock INT NOT NULL COMMENT '秒杀库存', start_time TIMESTAMP NOT NULL COMMENT '开始时间', end_time TIMESTAMP NOT NULL COMMENT '结束时间', - status TINYINT DEFAULT 1 COMMENT '状态:1-未开始,2-进行中,3-已结束', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - - FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, - INDEX idx_product_id (product_id), - INDEX idx_start_time (start_time), - INDEX idx_end_time (end_time), - INDEX idx_status (status), - INDEX idx_created_at (created_at) + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-未开始,2-进行中,3-已结束', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + CONSTRAINT fk_flash_sales_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + INDEX idx_flash_sales_product_id (product_id), + INDEX idx_flash_sales_start_time (start_time), + INDEX idx_flash_sales_end_time (end_time), + INDEX idx_flash_sales_status (status), + INDEX idx_flash_sales_created_at (created_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT ='秒杀活动表'; -- ================================ --- 4. 订单表 +-- 4. 订单主表 -- ================================ -CREATE TABLE IF NOT EXISTS orders -( +CREATE TABLE IF NOT EXISTS orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '订单ID', order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号', - group_no VARCHAR(64) COMMENT '聚合订单号', + group_no VARCHAR(64) COMMENT '聚合订单号(兼容旧数据)', user_id BIGINT NOT NULL COMMENT '用户ID', - product_id BIGINT NOT NULL COMMENT '商品ID', - quantity INT NOT NULL DEFAULT 1 COMMENT '购买数量', - total_price DECIMAL(10, 2) NOT NULL COMMENT '总价', - status TINYINT DEFAULT 1 COMMENT '状态:1-待支付,2-已支付,3-已发货,4-已完成,5-已取消', - order_type TINYINT DEFAULT 1 COMMENT '订单类型:1-普通订单,2-秒杀订单', + product_id BIGINT NOT NULL COMMENT '兼容字段:主商品ID', + flash_sale_id BIGINT COMMENT '秒杀活动ID', + group_buying_group_id BIGINT COMMENT '拼团团组ID', + quantity INT NOT NULL DEFAULT 1 COMMENT '兼容字段:总购买数量', + total_price DECIMAL(10, 2) NOT NULL COMMENT '订单总价', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-待支付,2-已支付,3-已发货,4-已完成,5-已取消', + order_type TINYINT NOT NULL DEFAULT 1 COMMENT '订单类型:1-普通订单,2-秒杀订单', receiver_name VARCHAR(100) COMMENT '收货人', receiver_phone VARCHAR(20) COMMENT '收货手机号', receiver_address VARCHAR(255) COMMENT '收货地址', @@ -102,30 +101,26 @@ CREATE TABLE IF NOT EXISTS orders paid_at TIMESTAMP NULL COMMENT '支付时间', shipped_at TIMESTAMP NULL COMMENT '发货时间', completed_at TIMESTAMP NULL COMMENT '完成时间', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, - INDEX idx_user_id (user_id), - INDEX idx_product_id (product_id), - INDEX idx_status (status), - INDEX idx_order_type (order_type), - INDEX idx_created_at (created_at), - INDEX idx_user_product (user_id, product_id) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_orders_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + INDEX idx_orders_order_no (order_no), + INDEX idx_orders_group_no (group_no), + INDEX idx_orders_user_id (user_id), + INDEX idx_orders_product_id (product_id), + INDEX idx_orders_flash_sale_id (flash_sale_id), + INDEX idx_orders_status (status), + INDEX idx_orders_order_type (order_type), + INDEX idx_orders_created_at (created_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 - COLLATE = utf8mb4_unicode_ci COMMENT ='订单表'; - - - - + COLLATE = utf8mb4_unicode_ci COMMENT ='订单主表'; -- ================================ -- 5. 订单明细表 -- ================================ -CREATE TABLE IF NOT EXISTS order_items -( +CREATE TABLE IF NOT EXISTS order_items ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '明细ID', order_id BIGINT NOT NULL COMMENT '主订单ID', product_id BIGINT NOT NULL COMMENT '商品ID', @@ -134,10 +129,9 @@ CREATE TABLE IF NOT EXISTS order_items price DECIMAL(10, 2) NOT NULL COMMENT '下单单价', quantity INT NOT NULL COMMENT '购买数量', subtotal DECIMAL(10, 2) NOT NULL COMMENT '小计', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - - FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, - FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, + CONSTRAINT fk_order_items_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, INDEX idx_order_items_order_id (order_id), INDEX idx_order_items_product_id (product_id) ) ENGINE = InnoDB @@ -147,8 +141,7 @@ CREATE TABLE IF NOT EXISTS order_items -- ================================ -- 6. 用户地址表 -- ================================ -CREATE TABLE IF NOT EXISTS user_addresses -( +CREATE TABLE IF NOT EXISTS user_addresses ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '地址ID', user_id BIGINT NOT NULL COMMENT '用户ID', name VARCHAR(100) NOT NULL COMMENT '收货人', @@ -157,13 +150,12 @@ CREATE TABLE IF NOT EXISTS user_addresses city VARCHAR(50) COMMENT '城市', district VARCHAR(50) COMMENT '区县', address VARCHAR(255) NOT NULL COMMENT '详细地址', - is_default TINYINT DEFAULT 0 COMMENT '是否默认地址', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - INDEX idx_address_user_id (user_id), - INDEX idx_address_default (is_default) + is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + CONSTRAINT fk_user_addresses_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + INDEX idx_user_addresses_user_id (user_id), + INDEX idx_user_addresses_default (is_default) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT ='用户地址表'; @@ -171,61 +163,127 @@ CREATE TABLE IF NOT EXISTS user_addresses -- ================================ -- 7. 商品评价表 -- ================================ -CREATE TABLE IF NOT EXISTS product_reviews -( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '评价ID', - product_id BIGINT NOT NULL COMMENT '商品ID', - user_id BIGINT NOT NULL COMMENT '用户ID', - order_id BIGINT NOT NULL COMMENT '订单ID', - rating TINYINT NOT NULL DEFAULT 5 COMMENT '评分', - content TEXT NOT NULL COMMENT '评价内容', - status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-显示,0-隐藏', +CREATE TABLE IF NOT EXISTS product_reviews ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '评价ID', + product_id BIGINT NOT NULL COMMENT '商品ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + rating TINYINT NOT NULL DEFAULT 5 COMMENT '评分', + content TEXT NOT NULL COMMENT '评价内容', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-显示,0-隐藏', admin_reply TEXT COMMENT '管理员回复', - replied_at TIMESTAMP NULL COMMENT '回复时间', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - - FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, - INDEX idx_review_product_id (product_id), - INDEX idx_review_user_id (user_id), - UNIQUE KEY uk_review_order_user (order_id, user_id) + replied_at TIMESTAMP NULL COMMENT '回复时间', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + CONSTRAINT fk_product_reviews_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT fk_product_reviews_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_product_reviews_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, + UNIQUE KEY uk_review_order_user_product (order_id, user_id, product_id), + INDEX idx_product_reviews_product_id (product_id), + INDEX idx_product_reviews_user_id (user_id), + INDEX idx_product_reviews_status (status) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT ='商品评价表'; - - -- ================================ -- 8. 用户收藏表 -- ================================ -CREATE TABLE IF NOT EXISTS user_favorites -( +CREATE TABLE IF NOT EXISTS user_favorites ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '收藏ID', user_id BIGINT NOT NULL COMMENT '用户ID', product_id BIGINT NOT NULL COMMENT '商品ID', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + CONSTRAINT fk_user_favorites_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_user_favorites_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, UNIQUE KEY uk_favorite_user_product (user_id, product_id), - INDEX idx_favorite_user_id (user_id), - INDEX idx_favorite_product_id (product_id) + INDEX idx_user_favorites_user_id (user_id), + INDEX idx_user_favorites_product_id (product_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT ='用户收藏表'; -- ================================ --- 9. 创建视图(可选) +-- 9. 视图 -- ================================ +-- ================================ +-- 9. 拼团活动表 +-- ================================ +CREATE TABLE IF NOT EXISTS group_buying ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '拼团活动ID', + product_id BIGINT NOT NULL COMMENT '商品ID', + group_price DECIMAL(10, 2) NOT NULL COMMENT '拼团价格', + required_members INT NOT NULL DEFAULT 2 COMMENT '成团人数', + duration_minutes INT NOT NULL DEFAULT 1440 COMMENT '拼团有效期(分钟)', + total_stock INT NOT NULL COMMENT '总库存', + remaining_stock INT NOT NULL COMMENT '剩余库存', + max_per_user INT NOT NULL DEFAULT 1 COMMENT '每人限购', + status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-草稿 1-未开始 2-进行中 3-已结束', + start_time DATETIME NOT NULL COMMENT '开始时间', + end_time DATETIME NOT NULL COMMENT '结束时间', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + CONSTRAINT fk_group_buying_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + INDEX idx_group_buying_product_id (product_id), + INDEX idx_group_buying_status (status), + INDEX idx_group_buying_start_time (start_time), + INDEX idx_group_buying_end_time (end_time) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT ='拼团活动表'; --- 活跃秒杀活动视图 +-- ================================ +-- 10. 拼团团组表 +-- ================================ +CREATE TABLE IF NOT EXISTS group_buying_group ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '团组ID', + group_no VARCHAR(64) NOT NULL UNIQUE COMMENT '团号', + group_buying_id BIGINT NOT NULL COMMENT '关联拼团活动', + leader_user_id BIGINT NOT NULL COMMENT '团长用户ID', + required_members INT NOT NULL COMMENT '需要人数', + current_members INT NOT NULL DEFAULT 1 COMMENT '当前人数', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-拼团中 2-已成团 3-已失败(超时)', + expire_time DATETIME NOT NULL COMMENT '过期时间', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + completed_at TIMESTAMP NULL COMMENT '成团时间', + CONSTRAINT fk_gbg_group_buying FOREIGN KEY (group_buying_id) REFERENCES group_buying (id) ON DELETE CASCADE, + CONSTRAINT fk_gbg_leader FOREIGN KEY (leader_user_id) REFERENCES users (id) ON DELETE CASCADE, + INDEX idx_gbg_group_no (group_no), + INDEX idx_gbg_group_buying_id (group_buying_id), + INDEX idx_gbg_status (status), + INDEX idx_gbg_expire_time (expire_time) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT ='拼团团组表'; + +-- ================================ +-- 11. 拼团成员表 +-- ================================ +CREATE TABLE IF NOT EXISTS group_buying_member ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID', + group_id BIGINT NOT NULL COMMENT '关联团组', + user_id BIGINT NOT NULL COMMENT '用户ID', + order_id BIGINT COMMENT '关联订单', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-已加入 2-已成团 3-已退出', + joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + CONSTRAINT fk_gbm_group FOREIGN KEY (group_id) REFERENCES group_buying_group (id) ON DELETE CASCADE, + CONSTRAINT fk_gbm_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + UNIQUE KEY uk_group_user (group_id, user_id), + INDEX idx_gbm_group_id (group_id), + INDEX idx_gbm_user_id (user_id), + INDEX idx_gbm_order_id (order_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT ='拼团成员表'; + +-- ================================ +-- 12. 视图 +-- ================================ CREATE OR REPLACE VIEW active_flash_sales AS SELECT fs.id, fs.product_id, - p.name as product_name, - p.price as original_price, + p.name AS product_name, + p.price AS original_price, fs.flash_price, fs.flash_stock, fs.start_time, @@ -233,35 +291,20 @@ SELECT fs.id, fs.status, p.image_url FROM flash_sales fs - JOIN products p ON fs.product_id = p.id +JOIN products p ON fs.product_id = p.id WHERE fs.status = 2 AND fs.start_time <= NOW() AND fs.end_time > NOW() AND p.status = 1; --- 订单统计视图 CREATE OR REPLACE VIEW order_statistics AS -SELECT DATE(created_at) as order_date, - COUNT(*) as total_orders, - SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as pending_orders, - SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as paid_orders, - SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) as completed_orders, - SUM(CASE WHEN order_type = 2 THEN 1 ELSE 0 END) as flash_sale_orders, - SUM(total_price) as total_amount +SELECT DATE(created_at) AS order_date, + COUNT(*) AS total_orders, + SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS pending_orders, + SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS paid_orders, + SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS completed_orders, + SUM(CASE WHEN order_type = 2 THEN 1 ELSE 0 END) AS flash_sale_orders, + SUM(total_price) AS total_amount FROM orders GROUP BY DATE(created_at) ORDER BY order_date DESC; - --- ================================ --- 6. 显示表结构 --- ================================ -SHOW TABLES; - --- 显示表结构信息 -SELECT TABLE_NAME as '表名', - TABLE_COMMENT as '表注释', - TABLE_ROWS as '估计行数' -FROM information_schema.TABLES -WHERE TABLE_SCHEMA = 'flash_sale_db' - AND TABLE_TYPE = 'BASE TABLE' -ORDER BY TABLE_NAME; diff --git a/src/main/resources/sql/test-data.sql b/src/main/resources/sql/test-data.sql index ad6a452..16a2116 100644 --- a/src/main/resources/sql/test-data.sql +++ b/src/main/resources/sql/test-data.sql @@ -1,161 +1,126 @@ --- 秒杀系统测试数据SQL脚本 --- 包含演示账号、测试商品、秒杀活动等数据 +-- 测试业务数据初始化脚本 +-- 依赖:请先执行 schema.sql 和 demo-users.sql --- 创建数据库(如果不存在) -CREATE DATABASE IF NOT EXISTS flash_sale_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE flash_sale_db; --- 清理现有数据(谨慎使用) --- DELETE FROM orders WHERE id > 0; --- DELETE FROM flash_sales WHERE id > 0; --- DELETE FROM products WHERE id > 0; --- DELETE FROM users WHERE id > 0; - --- 重置自增ID --- ALTER TABLE users AUTO_INCREMENT = 1; --- ALTER TABLE products AUTO_INCREMENT = 1; --- ALTER TABLE flash_sales AUTO_INCREMENT = 1; --- ALTER TABLE orders AUTO_INCREMENT = 1; +SET FOREIGN_KEY_CHECKS = 0; +DELETE FROM user_favorites; +DELETE FROM product_reviews; +DELETE FROM user_addresses; +DELETE FROM order_items; +DELETE FROM orders; +DELETE FROM flash_sales; +DELETE FROM products; +DELETE FROM users WHERE username LIKE 'testuser%'; +ALTER TABLE products AUTO_INCREMENT = 1; +ALTER TABLE flash_sales AUTO_INCREMENT = 1; +ALTER TABLE orders AUTO_INCREMENT = 1; +ALTER TABLE order_items AUTO_INCREMENT = 1; +ALTER TABLE user_addresses AUTO_INCREMENT = 1; +ALTER TABLE product_reviews AUTO_INCREMENT = 1; +ALTER TABLE user_favorites AUTO_INCREMENT = 1; +SET FOREIGN_KEY_CHECKS = 1; -- ================================ --- 1. 插入测试用户数据 +-- 1. 测试用户 -- ================================ - -INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at) +INSERT INTO users (username, password, email, phone, avatar, role, status, created_at, updated_at) VALUES --- 演示账号(密码都是明文,实际应用中应该加密) -('demo1', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo1@example.com', '13800138001', 'USER', 1, NOW(), - NOW()), -('demo2', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo2@example.com', '13800138002', 'USER', 1, NOW(), - NOW()), -('admin', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1, NOW(), - NOW()), - --- 普通测试用户 -('testuser1', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test1@example.com', '13800138003', 'USER', 1, - NOW(), NOW()), -('testuser2', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test2@example.com', '13800138004', 'USER', 1, - NOW(), NOW()), -('testuser3', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test3@example.com', '13800138005', 'USER', 1, - NOW(), NOW()), -('testuser4', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test4@example.com', '13800138006', 'USER', 1, - NOW(), NOW()), -('testuser5', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test5@example.com', '13800138007', 'USER', 1, - NOW(), NOW()); + ('testuser1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test1@example.com', '13800138003', '', 'USER', 1, NOW(), NOW()), + ('testuser2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test2@example.com', '13800138004', '', 'USER', 1, NOW(), NOW()), + ('testuser3', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test3@example.com', '13800138005', '', 'USER', 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE + email = VALUES(email), + phone = VALUES(phone), + updated_at = NOW(); -- ================================ --- 2. 插入测试商品数据 +-- 2. 商品 -- ================================ - INSERT INTO products (name, description, price, category, stock, image_url, status, created_at, updated_at) VALUES --- 电子产品类 -('iPhone 15 Pro Max', '苹果最新旗舰手机,A17 Pro芯片,钛金属设计', 9999.00, '电子产品', 100, '/images/iphone15.jpg', 1, NOW(), NOW()), -('MacBook Pro 16英寸', 'M3 Max芯片,36GB内存,1TB存储', 25999.00, '电子产品', 50, '/images/macbook.jpg', 1, NOW(), NOW()), -('iPad Air', '10.9英寸液晶显示屏,M1芯片', 4399.00, '电子产品', 80, '/images/ipad.jpg', 1, NOW(), NOW()), -('AirPods Pro 2', '主动降噪无线耳机,空间音频', 1899.00, '电子产品', 200, '/images/airpods.jpg', 1, NOW(), NOW()), -('Apple Watch Series 9', '健康监测,GPS+蜂窝网络', 3199.00, '电子产品', 150, '/images/watch.jpg', 1, NOW(), NOW()), - --- 家电类 -('小米电视 65英寸', '4K超高清,120Hz刷新率', 2999.00, '家电', 60, '/images/tv.jpg', 1, NOW(), NOW()), -('戴森吸尘器 V15', '激光显微尘,强劲吸力', 4690.00, '家电', 40, '/images/dyson.jpg', 1, NOW(), NOW()), -('美的空调 1.5匹', '变频节能,静音运行', 2599.00, '家电', 80, '/images/airconditioner.jpg', 1, NOW(), NOW()), - --- 服装类 -('Nike Air Jordan 1', '经典篮球鞋,限量版配色', 1299.00, '服饰鞋包', 120, '/images/jordan.jpg', 1, NOW(), NOW()), -('Adidas Ultra Boost', '缓震跑鞋,Boost中底', 1599.00, '服饰鞋包', 100, '/images/ultraboost.jpg', 1, NOW(), NOW()), - --- 图书类 -('深入理解Java虚拟机', 'JVM原理与实践,第3版', 89.00, '图书音像', 500, '/images/jvm-book.jpg', 1, NOW(), NOW()), -('Redis设计与实现', 'Redis内部机制详解', 79.00, '图书音像', 300, '/images/redis-book.jpg', 1, NOW(), NOW()), - --- 食品类 -('茅台酒 53度 500ml', '国酒茅台,收藏佳品', 2680.00, '食品饮料', 30, '/images/maotai.jpg', 1, NOW(), NOW()), -('五常大米 10kg', '东北优质大米,香甜可口', 168.00, '食品饮料', 200, '/images/rice.jpg', 1, NOW(), NOW()), - --- 美妆类 -('SK-II神仙水 230ml', '护肤精华,改善肌肤', 1690.00, '美妆个护', 80, '/images/skii.jpg', 1, NOW(), NOW()); + ('iPhone 15 Pro Max', '苹果最新旗舰手机,A17 Pro 芯片,钛金属设计。', 9999.00, '电子产品', 100, '/images/iphone15.svg', 1, NOW(), NOW()), + ('MacBook Pro 16英寸', 'M3 Max 芯片,36GB 内存,1TB 存储。', 25999.00, '电子产品', 50, '/images/macbook.svg', 1, NOW(), NOW()), + ('iPad Air', '10.9 英寸显示屏,轻薄便携。', 4399.00, '电子产品', 80, '/images/ipad.svg', 1, NOW(), NOW()), + ('AirPods Pro 2', '主动降噪无线耳机。', 1899.00, '电子产品', 200, '/images/default-product.svg', 1, NOW(), NOW()), + ('Apple Watch Series 9', '健康监测与运动记录。', 3199.00, '电子产品', 150, '/images/default-product.svg', 1, NOW(), NOW()), + ('小米电视 65英寸', '4K 超高清,120Hz 刷新率。', 2999.00, '家电', 60, '/images/default-product.svg', 1, NOW(), NOW()), + ('戴森吸尘器 V15', '激光显微尘,强劲吸力。', 4690.00, '家电', 40, '/images/default-product.svg', 1, NOW(), NOW()), + ('Nike Air Jordan 1', '经典篮球鞋,限量版配色。', 1299.00, '服饰鞋包', 120, '/images/default-product.svg', 1, NOW(), NOW()), + ('深入理解Java虚拟机', 'JVM 原理与实践,第 3 版。', 89.00, '图书音像', 500, '/images/default-product.svg', 1, NOW(), NOW()), + ('五常大米 10kg', '东北优质大米,香甜可口。', 168.00, '食品饮料', 200, '/images/default-product.svg', 1, NOW(), NOW()); -- ================================ --- 3. 插入秒杀活动数据 +-- 3. 秒杀活动 -- ================================ - INSERT INTO flash_sales (product_id, flash_price, flash_stock, start_time, end_time, status, created_at, updated_at) VALUES --- 正在进行的秒杀活动 -(1, 7999.00, 20, DATE_SUB(NOW(), INTERVAL 10 MINUTE), DATE_ADD(NOW(), INTERVAL 2 HOUR), 2, NOW(), NOW()), -(4, 1299.00, 50, DATE_SUB(NOW(), INTERVAL 5 MINUTE), DATE_ADD(NOW(), INTERVAL 1 HOUR), 2, NOW(), NOW()), -(6, 1999.00, 15, DATE_SUB(NOW(), INTERVAL 1 MINUTE), DATE_ADD(NOW(), INTERVAL 3 HOUR), 2, NOW(), NOW()), - --- 即将开始的秒杀活动 -(2, 19999.00, 10, DATE_ADD(NOW(), INTERVAL 30 MINUTE), DATE_ADD(NOW(), INTERVAL 4 HOUR), 1, NOW(), NOW()), -(9, 899.00, 30, DATE_ADD(NOW(), INTERVAL 1 HOUR), DATE_ADD(NOW(), INTERVAL 5 HOUR), 1, NOW(), NOW()), -(13, 1999.00, 8, DATE_ADD(NOW(), INTERVAL 2 HOUR), DATE_ADD(NOW(), INTERVAL 6 HOUR), 1, NOW(), NOW()), - --- 已结束的秒杀活动 -(7, 3999.00, 10, DATE_SUB(NOW(), INTERVAL 2 HOUR), DATE_SUB(NOW(), INTERVAL 30 MINUTE), 3, NOW(), NOW()), -(11, 59.00, 100, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 22 HOUR), 3, NOW(), NOW()); + (1, 7999.00, 20, DATE_SUB(NOW(), INTERVAL 10 MINUTE), DATE_ADD(NOW(), INTERVAL 2 HOUR), 2, NOW(), NOW()), + (4, 1299.00, 50, DATE_SUB(NOW(), INTERVAL 5 MINUTE), DATE_ADD(NOW(), INTERVAL 1 HOUR), 2, NOW(), NOW()), + (6, 1999.00, 15, DATE_SUB(NOW(), INTERVAL 1 MINUTE), DATE_ADD(NOW(), INTERVAL 3 HOUR), 2, NOW(), NOW()), + (2, 19999.00, 10, DATE_ADD(NOW(), INTERVAL 30 MINUTE), DATE_ADD(NOW(), INTERVAL 4 HOUR), 1, NOW(), NOW()), + (8, 899.00, 30, DATE_ADD(NOW(), INTERVAL 1 HOUR), DATE_ADD(NOW(), INTERVAL 5 HOUR), 1, NOW(), NOW()), + (9, 59.00, 100, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 22 HOUR), 3, NOW(), NOW()); -- ================================ --- 4. 插入测试订单数据 +-- 4. 地址 -- ================================ +INSERT INTO user_addresses (user_id, name, phone, province, city, district, address, is_default, created_at, updated_at) +SELECT id, '演示用户一', '13800138001', '上海市', '上海市', '浦东新区', '张江高科技园区 100 号', 1, NOW(), NOW() FROM users WHERE username = 'demo1' +UNION ALL +SELECT id, '演示用户二', '13800138002', '浙江省', '杭州市', '西湖区', '文三路 88 号', 1, NOW(), NOW() FROM users WHERE username = 'demo2' +UNION ALL +SELECT id, '测试用户一', '13800138003', '广东省', '深圳市', '南山区', '科技园科苑路 18 号', 1, NOW(), NOW() FROM users WHERE username = 'testuser1'; -INSERT INTO orders (user_id, product_id, quantity, total_price, status, order_type, created_at, updated_at) +-- ================================ +-- 5. 订单主表 +-- ================================ +INSERT INTO orders ( + order_no, group_no, user_id, product_id, flash_sale_id, quantity, total_price, status, order_type, + receiver_name, receiver_phone, receiver_address, remark, payment_method, + paid_at, shipped_at, completed_at, created_at, updated_at +) VALUES --- demo1用户的订单 -(1, 11, 1, 89.00, 4, 1, DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)), -(1, 12, 1, 79.00, 2, 1, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)), - --- demo2用户的订单 -(2, 14, 1, 168.00, 3, 1, DATE_SUB(NOW(), INTERVAL 3 HOUR), DATE_SUB(NOW(), INTERVAL 2 HOUR)), -(2, 7, 1, 3999.00, 1, 2, DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 1 HOUR)), - --- 其他用户的订单 -(4, 15, 1, 1690.00, 2, 1, DATE_SUB(NOW(), INTERVAL 6 HOUR), DATE_SUB(NOW(), INTERVAL 5 HOUR)), -(5, 10, 1, 1599.00, 4, 1, DATE_SUB(NOW(), INTERVAL 12 HOUR), DATE_SUB(NOW(), INTERVAL 10 HOUR)), -(6, 8, 1, 2599.00, 3, 1, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 20 HOUR)), -(7, 5, 1, 3199.00, 2, 1, DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)); + ('ORD202603110001', NULL, (SELECT id FROM users WHERE username = 'demo1'), 9, NULL, 1, 89.00, 4, 1, '演示用户一', '13800138001', '上海市 上海市 浦东新区 张江高科技园区 100 号', '已完成测试订单', 'ALIPAY', DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)), + ('ORD202603110002', NULL, (SELECT id FROM users WHERE username = 'demo1'), 4, NULL, 1, 1899.00, 2, 1, '演示用户一', '13800138001', '上海市 上海市 浦东新区 张江高科技园区 100 号', '待发货测试订单', 'WECHAT', DATE_SUB(NOW(), INTERVAL 1 DAY), NULL, NULL, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)), + ('ORD202603110003', NULL, (SELECT id FROM users WHERE username = 'demo2'), 10, NULL, 1, 168.00, 3, 1, '演示用户二', '13800138002', '浙江省 杭州市 西湖区 文三路 88 号', '已发货测试订单', 'ONLINE', DATE_SUB(NOW(), INTERVAL 4 HOUR), DATE_SUB(NOW(), INTERVAL 2 HOUR), NULL, DATE_SUB(NOW(), INTERVAL 6 HOUR), DATE_SUB(NOW(), INTERVAL 2 HOUR)), + ('ORD202603110004', NULL, (SELECT id FROM users WHERE username = 'demo2'), 4, 2, 1, 1299.00, 1, 2, '演示用户二', '13800138002', '浙江省 杭州市 西湖区 文三路 88 号', '秒杀待支付订单', NULL, NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 1 HOUR)), + ('ORD202603110005', NULL, (SELECT id FROM users WHERE username = 'testuser1'), 1, NULL, 2, 11798.00, 2, 1, '测试用户一', '13800138003', '广东省 深圳市 南山区 科技园科苑路 18 号', '多商品主订单', 'ONLINE', DATE_SUB(NOW(), INTERVAL 5 HOUR), NULL, NULL, DATE_SUB(NOW(), INTERVAL 5 HOUR), DATE_SUB(NOW(), INTERVAL 5 HOUR)); -- ================================ --- 5. 查询验证数据 +-- 6. 订单明细 -- ================================ - --- 查看用户数据 -SELECT 'Users:' as table_name; -SELECT id, username, email, phone, status, created_at -FROM users -ORDER BY id; - --- 查看商品数据 -SELECT 'Products:' as table_name; -SELECT id, name, price, stock, status -FROM products -ORDER BY id -LIMIT 10; - --- 查看秒杀活动数据 -SELECT 'Flash Sales:' as table_name; -SELECT fs.id, p.name as product_name, fs.flash_price, fs.flash_stock, fs.start_time, fs.end_time, fs.status -FROM flash_sales fs - JOIN products p ON fs.product_id = p.id -ORDER BY fs.id; - --- 查看订单数据 -SELECT 'Orders:' as table_name; -SELECT o.id, u.username, p.name as product_name, o.quantity, o.total_price, o.status, o.order_type -FROM orders o - JOIN users u ON o.user_id = u.id - JOIN products p ON o.product_id = p.id -ORDER BY o.id; +INSERT INTO order_items (order_id, product_id, product_name, product_image_url, price, quantity, subtotal, created_at) +SELECT o.id, 9, '深入理解Java虚拟机', '/images/default-product.svg', 89.00, 1, 89.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110001' +UNION ALL +SELECT o.id, 4, 'AirPods Pro 2', '/images/default-product.svg', 1899.00, 1, 1899.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110002' +UNION ALL +SELECT o.id, 10, '五常大米 10kg', '/images/default-product.svg', 168.00, 1, 168.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110003' +UNION ALL +SELECT o.id, 4, 'AirPods Pro 2', '/images/default-product.svg', 1299.00, 1, 1299.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110004' +UNION ALL +SELECT o.id, 1, 'iPhone 15 Pro Max', '/images/iphone15.svg', 9999.00, 1, 9999.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005' +UNION ALL +SELECT o.id, 9, '深入理解Java虚拟机', '/images/default-product.svg', 89.00, 2, 178.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005' +UNION ALL +SELECT o.id, 10, '五常大米 10kg', '/images/default-product.svg', 168.00, 1, 168.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005' +UNION ALL +SELECT o.id, 4, 'AirPods Pro 2', '/images/default-product.svg', 1899.00, 1, 1899.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005'; -- ================================ --- 6. 统计信息 +-- 7. 评价 -- ================================ +INSERT INTO product_reviews (product_id, user_id, order_id, rating, content, status, admin_reply, replied_at, created_at, updated_at) +VALUES + (9, (SELECT id FROM users WHERE username = 'demo1'), (SELECT id FROM orders WHERE order_no = 'ORD202603110001'), 5, '内容很扎实,适合深入学习 JVM。', 1, '感谢支持,后续会持续补充相关图书。', NOW(), DATE_SUB(NOW(), INTERVAL 1 DAY), NOW()), + (4, (SELECT id FROM users WHERE username = 'demo1'), (SELECT id FROM orders WHERE order_no = 'ORD202603110002'), 4, '耳机效果不错,降噪很明显。', 1, NULL, NULL, DATE_SUB(NOW(), INTERVAL 12 HOUR), DATE_SUB(NOW(), INTERVAL 12 HOUR)); -SELECT 'Statistics:' as info; -SELECT (SELECT COUNT(*) FROM users) as total_users, - (SELECT COUNT(*) FROM products) as total_products, - (SELECT COUNT(*) FROM flash_sales) as total_flash_sales, - (SELECT COUNT(*) FROM orders) as total_orders, - (SELECT COUNT(*) FROM flash_sales WHERE status = 2) as active_flash_sales, - (SELECT COUNT(*) FROM orders WHERE status = 1) as pending_orders; +-- ================================ +-- 8. 收藏 +-- ================================ +INSERT INTO user_favorites (user_id, product_id, created_at) +VALUES + ((SELECT id FROM users WHERE username = 'demo1'), 1, NOW()), + ((SELECT id FROM users WHERE username = 'demo1'), 4, NOW()), + ((SELECT id FROM users WHERE username = 'demo2'), 2, NOW()), + ((SELECT id FROM users WHERE username = 'testuser1'), 9, NOW()); diff --git a/src/main/resources/sql/update-passwords.sql b/src/main/resources/sql/update-passwords.sql deleted file mode 100644 index 97c1dc4..0000000 --- a/src/main/resources/sql/update-passwords.sql +++ /dev/null @@ -1,42 +0,0 @@ --- 更新演示账号密码为BCrypt格式 --- 这些是使用BCryptPasswordEncoder生成的正确哈希值 - -USE flash_sale_db; - --- 删除现有演示用户(如果存在) -DELETE -FROM users -WHERE username IN ('demo1', 'demo2', 'admin'); - --- 插入使用BCrypt加密的演示用户 --- demo1/demo2 密码: 123456 (BCrypt哈希) --- admin 密码: admin123 (BCrypt哈希) -INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at) -VALUES ('demo1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo1@example.com', '13800138001', 'USER', 1, - NOW(), NOW()), - ('demo2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo2@example.com', '13800138002', 'USER', 1, - NOW(), NOW()), - ('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1, - NOW(), NOW()); - --- 验证插入结果 -SELECT id, - username, - email, - phone, - status, - SUBSTRING(password, 1, 30) as password_hash_preview, - created_at -FROM users -WHERE username IN ('demo1', 'demo2', 'admin') -ORDER BY username; - --- 显示账号信息 -SELECT '=== 演示账号信息 ===' as info; -SELECT CONCAT(username, ' / ', CASE - WHEN username = 'admin' THEN 'admin123' - ELSE '123456' - END) as '用户名/密码' -FROM users -WHERE username IN ('demo1', 'demo2', 'admin') -ORDER BY username; diff --git a/src/main/webapp/WEB-INF/views/admin/flashsales.jsp b/src/main/webapp/WEB-INF/views/admin/flashsales.jsp deleted file mode 100644 index 219cc47..0000000 --- a/src/main/webapp/WEB-INF/views/admin/flashsales.jsp +++ /dev/null @@ -1,1211 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="../common/header.jsp" %> - -
-
- - - - -
-
-

秒杀管理

-
-
- - -
-
-
- - -
-
-
- - -
-
-
- -
-
- -
-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - -
ID图片活动名称商品原价/秒杀价库存开始时间结束时间状态操作
- 加载中... -
-
- - - -
-
-
-
-
- - - - - - - - - - - - - - -<%@ include file="../common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/admin/index.jsp b/src/main/webapp/WEB-INF/views/admin/index.jsp deleted file mode 100644 index 6af0f26..0000000 --- a/src/main/webapp/WEB-INF/views/admin/index.jsp +++ /dev/null @@ -1,464 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="../common/header.jsp" %> - -
-
- - - - -
-
-

管理后台仪表盘

-
-
- -
-
-
- - -
-
-
-
-
-
-
- 总用户数 -
-
- -
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- 总商品数 -
-
- -
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- 活跃秒杀 -
-
- -
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- 今日订单 -
-
- -
-
-
- -
-
-
-
-
-
- - -
-
-
-
-
快速操作
-
- -
-
-
- - -
-
-
-
-
最近订单
-
-
-
- - - - - - - - - - - - - - - - -
订单号用户商品金额状态时间
- 加载中... -
-
-
-
-
- -
-
-
-
热门商品
-
-
-
-
- 加载中... -
-
-
-
-
-
-
-
-
- - - - - -<%@ include file="../common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/admin/monitor.jsp b/src/main/webapp/WEB-INF/views/admin/monitor.jsp deleted file mode 100644 index 1531298..0000000 --- a/src/main/webapp/WEB-INF/views/admin/monitor.jsp +++ /dev/null @@ -1,515 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - - - - - - 系统监控 - 管理后台 - - - - - - - - -
-
-
-

- 系统监控 - -

-
-
- - -
-
-
-
-
0%
-
CPU 使用率
-
-
-
-
-
-
-
0%
-
内存使用率
-
-
-
-
-
-
-
0
-
在线用户
-
-
-
-
-
-
-
0
-
今日请求
-
-
-
-
- - -
-
-
-
-
- 服务状态 -
- -
-
-
-
-
- - 应用服务 -
- 运行中 -
-
-
- - Redis 服务 -
- 检查中... -
-
-
- - MySQL 服务 -
- 检查中... -
-
-
-
-
-
-
-
-
- 系统性能趋势 -
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
- 秒杀活动监控 -
-
- -
-
-
-
-
- -

加载中...

-
-
-
-
-
-
-
-
-
- 系统告警 -
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
- 实时日志 -
-
- - -
-
-
-
-
[INFO] 系统监控页面已加载
-
-
-
-
-
-
- - - - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/admin/orders.jsp b/src/main/webapp/WEB-INF/views/admin/orders.jsp deleted file mode 100644 index 8e84d14..0000000 --- a/src/main/webapp/WEB-INF/views/admin/orders.jsp +++ /dev/null @@ -1,545 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="../common/header.jsp" %> - -
-
- - - - -
-
-

订单管理

-
-
- - -
-
-
- - -
-
-
- - -
-
-
- -
-
- -
-
- -
-
- - -
-
-
-
-
0
-

总订单数

-
-
-
-
-
-
-
0
-

已支付订单

-
-
-
-
-
-
-
0
-

待处理订单

-
-
-
-
-
-
-
¥0
-

总交易额

-
-
-
-
- - -
-
-
- - - - - - - - - - - - - - - - - - -
订单号用户商品信息数量总金额状态创建时间操作
- 加载中... -
-
- - - -
-
-
-
-
- - - - - - - - -<%@ include file="../common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/admin/products.jsp b/src/main/webapp/WEB-INF/views/admin/products.jsp deleted file mode 100644 index 4c92a01..0000000 --- a/src/main/webapp/WEB-INF/views/admin/products.jsp +++ /dev/null @@ -1,1341 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="../common/header.jsp" %> - - - -
-
- - - - -
-
-

商品管理

-
-
- - -
-
-
- - -
-
-
- - -
-
-
- -
-
- -
-
- - -
-
-
- - - - - - - - - - - - - - - - - - -
ID商品图片商品名称价格库存状态创建时间操作
- 加载中... -
-
- - - -
-
-
-
-
- - - - - - - - - - - - - - -<%@ include file="../common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/admin/users.jsp b/src/main/webapp/WEB-INF/views/admin/users.jsp deleted file mode 100644 index a99c0a8..0000000 --- a/src/main/webapp/WEB-INF/views/admin/users.jsp +++ /dev/null @@ -1,404 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="../common/header.jsp" %> - -
-
- - - - -
-
-

用户管理

-
-
- - -
-
-
- - -
-
-
- - -
-
-
- -
-
- -
-
- -
-
- - -
-
-
-
-
0
-

总用户数

-
-
-
-
-
-
-
0
-

活跃用户

-
-
-
-
-
-
-
0
-

今日新增

-
-
-
-
-
-
-
0
-

在线用户

-
-
-
-
- - -
-
-
- - - - - - - - - - - - - - - - - -
ID用户名邮箱手机号状态注册时间最后登录
- 加载中... -
-
- - - -
-
-
-
-
- - - - - -<%@ include file="../common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/cart.jsp b/src/main/webapp/WEB-INF/views/cart.jsp deleted file mode 100644 index be9c06e..0000000 --- a/src/main/webapp/WEB-INF/views/cart.jsp +++ /dev/null @@ -1,658 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="common/header.jsp" %> - -
-
-
- -
-
- - -
-
-
-
-
- 我的购物车 -
-
- -
-
- -
- -
- -

加载购物车中...

-
- - - - - - -
-
-
-
- - -
-
-
- 猜你喜欢 -
-
- -
-
-
-
- - - - - -<%@ include file="common/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/common/footer.jsp b/src/main/webapp/WEB-INF/views/common/footer.jsp deleted file mode 100644 index 655e2e2..0000000 --- a/src/main/webapp/WEB-INF/views/common/footer.jsp +++ /dev/null @@ -1,215 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - -
-
-
-
-
秒杀系统
-

基于Spring Boot + Redis构建的高并发秒杀系统

-

- Redis集群 | - 分布式锁 | - 高性能 -

-
-
-
核心功能
-
    -
  • 秒杀抢购
  • -
  • 购物车
  • -
  • 订单管理
  • -
  • 销量排行
  • -
-
-
-
技术特性
-
    -
  • Redis缓存
  • -
  • 防超卖机制
  • -
  • 接口限流
  • -
  • Lua脚本
  • -
-
-
- -
- -
-
-

- © 2025 秒杀系统演示项目. - - 基于Redis集群构建 -

-
-
-
- - - 在线用户: - - - - - - -
- -
-
-
-
-
-
- - - - - - - - diff --git a/src/main/webapp/WEB-INF/views/common/header.jsp b/src/main/webapp/WEB-INF/views/common/header.jsp deleted file mode 100644 index d3aa44c..0000000 --- a/src/main/webapp/WEB-INF/views/common/header.jsp +++ /dev/null @@ -1,288 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - - - - - ${pageTitle} - 秒杀系统 - - - - - - - - - - - - - - - - - -
- -
- - diff --git a/src/main/webapp/WEB-INF/views/error.jsp b/src/main/webapp/WEB-INF/views/error.jsp deleted file mode 100644 index ecc3bb7..0000000 --- a/src/main/webapp/WEB-INF/views/error.jsp +++ /dev/null @@ -1,83 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - -<%@ include file="common/header.jsp" %> - -
-
-
-
-
-

- 系统错误 -

-
-
-
- -
抱歉,系统遇到了一个错误
-

我们正在努力修复这个问题,请稍后再试。

-
- - - -
-
错误详情:
-

${error}

-
-
- - -
-
异常信息:
-

${exception.message}

-
-
- - -
- - - 返回首页 - - -
-
-
- - -
-
-
常见问题解决方案
-
-
-
-
-
如果页面无法加载:
-
    -
  • 检查网络连接
  • -
  • 清除浏览器缓存
  • -
  • 尝试刷新页面
  • -
-
-
-
如果功能异常:
-
    -
  • 重新登录账号
  • -
  • 检查输入信息
  • -
  • 联系系统管理员
  • -
-
-
-
-
-
-
-
- -<%@ include file="common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/flashsale-detail.jsp b/src/main/webapp/WEB-INF/views/flashsale-detail.jsp deleted file mode 100644 index fa996a6..0000000 --- a/src/main/webapp/WEB-INF/views/flashsale-detail.jsp +++ /dev/null @@ -1,873 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="common/header.jsp" %> - -
- -
-
- -
-
- - -
- -

加载秒杀详情中...

-
- - - - - - -
- - - - - -<%@ include file="common/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/flashsales.jsp b/src/main/webapp/WEB-INF/views/flashsales.jsp deleted file mode 100644 index c6aaec8..0000000 --- a/src/main/webapp/WEB-INF/views/flashsales.jsp +++ /dev/null @@ -1,915 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="common/header.jsp" %> - -
- -
-
- - -
-

- 秒杀活动 - 限时抢购,先到先得 -

-
- - - - -
-
-
-
- - -
-
-
-
-
-
- -
-
- -
-
-
- - -
-
-
- -
-
-
-
-
-
- - -
-
-
-
- -
0
-

即将开始

-
-
-
-
-
-
- -
0
-

正在抢购

-
-
-
-
-
-
- -
0
-

热门活动

-
-
-
-
-
-
- -
0
-

已结束

-
-
-
-
- - -
-
- -
- -

加载秒杀活动中...

-
- - - - - - - - - -
-
-
- - - - - -<%@ include file="common/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/index.jsp b/src/main/webapp/WEB-INF/views/index.jsp deleted file mode 100644 index a96656f..0000000 --- a/src/main/webapp/WEB-INF/views/index.jsp +++ /dev/null @@ -1,703 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="common/header.jsp" %> - - - - -
- -
-
-

- 正在秒杀 -

- - 查看全部 - -
- -
- -
- -

加载中...

-
-
-
- - -
-
-

- 热门商品 -

- - 查看全部 - -
- -
- -
- -

加载中...

-
-
-
- - -
-

- 系统特性 -

- -
-
-
-
- -
秒杀抢购
-

高并发秒杀系统,支持大量用户同时抢购

-
-
-
-
-
-
- -
防超卖
-

分布式锁机制,确保库存数据一致性

-
-
-
-
-
-
- -
Redis缓存
-

五种数据类型应用,毫秒级响应

-
-
-
-
-
-
- -
接口限流
-

多种限流策略,防止恶意刷单

-
-
-
-
-
-
- - - - - -<%@ include file="common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/login.jsp b/src/main/webapp/WEB-INF/views/login.jsp deleted file mode 100644 index 6e8dce5..0000000 --- a/src/main/webapp/WEB-INF/views/login.jsp +++ /dev/null @@ -1,248 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - -<%@ include file="common/header.jsp" %> - -
-
-
-
-
-

- 用户登录 -

-
-
-
-
- - -
-
- -
- -
- - -
-
-
- -
- - -
- -
- -
-
- -
- -
-

还没有账号?

- - 立即注册 - -
-
- - -
-
-
- 系统特性 -
-
-
-
-
- -
秒杀抢购
- 高并发秒杀系统 -
-
- -
防超卖
- 分布式锁机制 -
-
- -
Redis缓存
- 高性能缓存 -
-
- -
接口限流
- 防刷机制 -
-
-
-
-
-
-
- - - -<%@ include file="common/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/order-detail.jsp b/src/main/webapp/WEB-INF/views/order-detail.jsp deleted file mode 100644 index 1422d7a..0000000 --- a/src/main/webapp/WEB-INF/views/order-detail.jsp +++ /dev/null @@ -1,571 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="common/header.jsp" %> - -
-
-
- -
-
- - -
-
- 加载中... -
-

正在加载订单详情...

-
- - - - - - -
- - - - - - - - -<%@ include file="common/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/orders.jsp b/src/main/webapp/WEB-INF/views/orders.jsp deleted file mode 100644 index 24d9d5b..0000000 --- a/src/main/webapp/WEB-INF/views/orders.jsp +++ /dev/null @@ -1,861 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="common/header.jsp" %> - -
-
-
- -
-
- - -
-
-
-
-
-
- -
-
- -
-
-
- - -
-
-
- -
-
-
-
-
-
- - -
-
-
-
-
- 我的订单 -
-
- -
- -
- -

加载订单中...

-
- - - - - -
- - - -
-
-
-
-
- - - - - - - - -<%@ include file="common/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/product-detail.jsp b/src/main/webapp/WEB-INF/views/product-detail.jsp deleted file mode 100644 index 72fad38..0000000 --- a/src/main/webapp/WEB-INF/views/product-detail.jsp +++ /dev/null @@ -1,383 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> - - - - - - - 商品详情 - 秒杀系统 - - - - - - - -
- - -
-
- ${product.name} -
-
-

${product.name}

- - -
- 限时秒杀 -
- -
- ¥ - 原价:¥ -
- - - -
-
距离秒杀开始还有
-
-
- 00 - -
-
- 00 - -
-
- 00 - -
-
- 00 - -
-
-
-
- -
-
距离秒杀结束还有
-
-
- 00 - -
-
- 00 - -
-
- 00 - -
-
-
-
- -
-
秒杀活动已结束
-
-
-
-
- - -
- ¥ -
-
- -
-
-
- 库存: - - - ${flashSale.remainingStock} 件 - - - ${product.stock} 件 - - -
-
- 销量: - ${product.sales} 件 -
-
-
- -
- - - - - - - - -
- - - -
-
- - -
-
- - -
- -
-
商品描述
-

${product.description != null ? product.description : '暂无描述'}

-
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/products.jsp b/src/main/webapp/WEB-INF/views/products.jsp deleted file mode 100644 index 120cc14..0000000 --- a/src/main/webapp/WEB-INF/views/products.jsp +++ /dev/null @@ -1,505 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %> - - -<%@ include file="common/header.jsp" %> - - - -
- -
-
-

- - 商品列表 -

-

发现更多优质商品

-
-
- - -
-
-
-
- - -
-
-
- -
-
- -
-
- -
-
-
- - -
-
- 加载中... -
-

正在加载商品...

-
- - -
- -
- - - - - - -
- - - - - - -<%@ include file="common/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/profile.jsp b/src/main/webapp/WEB-INF/views/profile.jsp deleted file mode 100644 index cc81782..0000000 --- a/src/main/webapp/WEB-INF/views/profile.jsp +++ /dev/null @@ -1,493 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> - - - - - - - 个人中心 - 秒杀系统 - - - - - - - - -
-
-
-
- 头像 -
-
-

${user.username}

-

- ${user.email} - - - 加入时间: - -

-
-
-
-
- -
- -
-
-
-
-
0
-
总订单数
-
-
-
-
-
¥0.00
-
累计消费
-
-
-
-
-
0
-
秒杀成功
-
-
-
-
-
0
-
收藏商品
-
-
-
-
- - -
- - -
- -
-
-
-
- 个人信息 -
-
-
-
-
- - -
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
- -
-
-
-
- - -
-
-
-
- 订单历史 -
-
-
- -

加载中...

-
-
-
-
-
- - -
-
-
-
- 秒杀记录 -
-
-
- -

加载中...

-
-
-
-
-
- - -
-
-
-
- 账户设置 -
-
-
- - -
-
- - -
-
- - -
- -
-
-
-
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/register.jsp b/src/main/webapp/WEB-INF/views/register.jsp deleted file mode 100644 index fe1b047..0000000 --- a/src/main/webapp/WEB-INF/views/register.jsp +++ /dev/null @@ -1,383 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" language="java" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - -<%@ include file="common/header.jsp" %> - -
-
-
-
-
-

- 用户注册 -

-
-
-
-
- - -
-
- 用户名将作为您的登录凭证 -
-
- -
- -
- - -
-
-
-
-
- 密码强度:无 -
- -
- - -
-
- -
- - -
-
- -
- - -
-
- -
- - -
请同意用户协议和隐私政策
-
- -
- -
-
- -
- -
-

已有账号?

- - 立即登录 - -
-
-
-
-
-
- - - - - - - - - -<%@ include file="common/footer.jsp" %> diff --git a/src/test/java/com/org/flashsalesystem/service/FlashSaleServiceTest.java b/src/test/java/com/org/flashsalesystem/service/FlashSaleServiceTest.java index a86927a..2a35a48 100644 --- a/src/test/java/com/org/flashsalesystem/service/FlashSaleServiceTest.java +++ b/src/test/java/com/org/flashsalesystem/service/FlashSaleServiceTest.java @@ -80,7 +80,6 @@ class FlashSaleServiceTest { createDTO.setEndTime(LocalDateTime.now().plusHours(2)); when(productRepository.findById(1L)).thenReturn(Optional.of(testProduct)); - when(flashSaleRepository.findByProductId(1L)).thenReturn(Optional.empty()); when(flashSaleRepository.save(any(FlashSale.class))).thenReturn(testFlashSale); when(redisService.getString(anyString())).thenReturn("100"); @@ -92,6 +91,37 @@ class FlashSaleServiceTest { verify(flashSaleRepository).save(any(FlashSale.class)); } + @Test + @DisplayName("创建秒杀活动 - 同一商品允许多个活动") + void createFlashSale_MultipleActivitiesPerProductAllowed() { + FlashSaleDTO.CreateDTO createDTO = new FlashSaleDTO.CreateDTO(); + createDTO.setProductId(1L); + createDTO.setFlashPrice(new BigDecimal("49.00")); + createDTO.setFlashStock(50); + createDTO.setStartTime(LocalDateTime.now().plusHours(3)); + createDTO.setEndTime(LocalDateTime.now().plusHours(5)); + + FlashSale secondFlashSale = new FlashSale(); + secondFlashSale.setId(2L); + secondFlashSale.setProductId(1L); + secondFlashSale.setFlashPrice(new BigDecimal("49.00")); + secondFlashSale.setFlashStock(50); + secondFlashSale.setStartTime(createDTO.getStartTime()); + secondFlashSale.setEndTime(createDTO.getEndTime()); + secondFlashSale.setStatus(1); + + when(productRepository.findById(1L)).thenReturn(Optional.of(testProduct)); + when(flashSaleRepository.save(any(FlashSale.class))).thenReturn(secondFlashSale); + when(redisService.getString(anyString())).thenReturn("50"); + + FlashSaleDTO result = flashSaleService.createFlashSale(createDTO); + + assertNotNull(result); + assertEquals(2L, result.getId()); + assertEquals(1L, result.getProductId()); + verify(flashSaleRepository).save(any(FlashSale.class)); + } + @Test @DisplayName("创建秒杀活动 - 商品不存在") void createFlashSale_ProductNotFound() { @@ -108,6 +138,26 @@ class FlashSaleServiceTest { verifyNoMoreInteractions(flashSaleRepository); } + @Test + @DisplayName("创建秒杀活动 - 开始时间早于当前时间") + void createFlashSale_StartTimeBeforeNow() { + FlashSaleDTO.CreateDTO createDTO = new FlashSaleDTO.CreateDTO(); + createDTO.setProductId(1L); + createDTO.setFlashPrice(new BigDecimal("50.00")); + createDTO.setFlashStock(100); + createDTO.setStartTime(LocalDateTime.now().minusMinutes(1)); + createDTO.setEndTime(LocalDateTime.now().plusHours(2)); + + when(productRepository.findById(1L)).thenReturn(Optional.of(testProduct)); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> flashSaleService.createFlashSale(createDTO)); + + assertEquals("开始时间不能早于当前时间", exception.getMessage()); + verify(productRepository).findById(1L); + verify(flashSaleRepository, never()).save(any(FlashSale.class)); + } + @Test @DisplayName("获取秒杀库存") void getFlashSaleStock_Success() { @@ -154,4 +204,4 @@ class FlashSaleServiceTest { verify(flashSaleRepository).findById(999L); verifyNoMoreInteractions(redisService); } -} \ No newline at end of file +}