From c9d85b08154c1771fd743cfb2c2940aa207c37e1 Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Fri, 5 Jun 2026 11:11:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(module/seckill):=20=E9=99=90=E6=97=B6?= =?UTF-8?q?=E6=8A=A2=E8=B4=AD=20Redis=20Lua=20=E9=98=B2=E8=B6=85=E5=8D=96?= =?UTF-8?q?=20+=20=E4=B8=80=E4=BA=BA=E4=B8=80=E5=8D=95=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实体 SeckillActivity、SeckillProduct、SeckillOrder - 枚举:SeckillActivityStatusEnum(含 calcStatus 时间驱动动态状态)、SeckillResultEnum - RedisKey 追加 4 个抢购相关 key:SECKILL_STOCK / SECKILL_USER_RECORD / SECKILL_RESULT / SECKILL_WARMUP - Mapper:decrRemainStock / incrRemainStock(MySQL 兜底)、forceEnd、markAsPaid - resources/lua/seckill.lua:原子完成"限购校验 + 库存校验 + 扣库存 + 累计已购" - Service:seckillBuy(核心:Lua 防超卖 → 落库 → 唯一索引兜底 → 落库失败回滚 Redis) - Service:queryResult(轮询接口,从 Redis 取抢购结果) - Service:warmupActivity(管理端预热,把库存同步到 Redis) - Service:forceEnd / deleteActivity(清理 Redis) - DTO:SeckillBuyReq、SeckillActivitySaveReq、SeckillProductAddReq - VO:SeckillActivityVO(含动态状态 + 倒计时秒数)、SeckillProductVO(含实时 Redis 库存 + 已售百分比 + 折扣率)、SeckillResultVO - Controller:用户端(活动列表/详情/抢购/结果查询)+ 管理端(活动 CRUD + 预热 + 强制结束 + 商品管理) - 一人一单:seckill_order UNIQUE KEY (user_id, activity_id, sku_id) 兜底 - 抢购失败回滚:MySQL 落库异常时 INCRBY Redis 释放库存 feat(common/storage): 文件存储抽象接口 + Local / S3 双实现 - 抽象接口 FileStorage:store / delete / getAccessUrl / parsePathFromUrl - LocalFileStorage:本地 ./uploads/ 物理存储(默认) - S3FileStorage:S3 协议实现(RustFS / MinIO / AWS S3 / 阿里云 OSS) - S3StorageProperties:snack.s3.* 配置类 - @ConditionalOnProperty 自动按 snack.storage.type 切换 - 启动时自动 ensureBucket(headBucket + createBucket) - DisposableBean 容器关闭时关闭 S3Client 连接池 文件模块: - 实体 UploadFile(含 storageType / bizType 区分) - 枚举:FileBizTypeEnum(avatar/admin-avatar/product/sku/category/banner/notice/chat/common) - Service:upload / uploadWithUploader / deleteFile / deleteByPath - FileController:/api/file/upload + /api/file/{id} - 图片类型自动校验(jpg/png/gif/webp) - URL → path 反查(删除商品时根据 main_image 字段自动清理物理文件) - application.yml:snack.storage.type / snack.upload.* / snack.s3.* 配置 管理端: - AdminAvatarController /api/admin/avatar/upload - CategoryAdminController 加 /api/admin/category/{id}/icon - 删除分类时同步清理 icon 物理文件 --- admin-snack/src/api/file.ts | 50 +- .../src/components/business/ImageUploader.vue | 187 +++++++ admin-snack/src/views/product/list.vue | 84 ++- .../server/common/storage/FileStorage.java | 40 ++ .../common/storage/FileStoreResult.java | 30 + .../common/storage/LocalFileStorage.java | 131 +++++ .../server/common/storage/S3FileStorage.java | 204 +++++++ .../common/storage/S3StorageProperties.java | 38 ++ .../common/utils/SpringContextHolder.java | 33 ++ .../com/snack/server/constant/RedisKey.java | 14 + .../controller/AdminAvatarController.java | 36 ++ .../controller/CategoryAdminController.java | 15 + .../category/service/CategoryService.java | 5 + .../service/impl/CategoryServiceImpl.java | 26 + .../file/controller/FileController.java | 46 ++ .../server/module/file/entity/UploadFile.java | 51 ++ .../module/file/enums/FileBizTypeEnum.java | 36 ++ .../module/file/mapper/UploadFileMapper.java | 9 + .../module/file/service/FileService.java | 47 ++ .../file/service/impl/FileServiceImpl.java | 135 +++++ .../server/module/file/vo/FileUploadVO.java | 41 ++ .../controller/SeckillAdminController.java | 92 +++ .../seckill/controller/SeckillController.java | 56 ++ .../dto/req/SeckillActivitySaveReq.java | 39 ++ .../module/seckill/dto/req/SeckillBuyReq.java | 26 + .../seckill/dto/req/SeckillProductAddReq.java | 41 ++ .../seckill/entity/SeckillActivity.java | 48 ++ .../module/seckill/entity/SeckillOrder.java | 56 ++ .../module/seckill/entity/SeckillProduct.java | 61 ++ .../enums/SeckillActivityStatusEnum.java | 32 ++ .../seckill/enums/SeckillResultEnum.java | 22 + .../seckill/mapper/SeckillActivityMapper.java | 17 + .../seckill/mapper/SeckillOrderMapper.java | 26 + .../seckill/mapper/SeckillProductMapper.java | 25 + .../seckill/service/SeckillService.java | 82 +++ .../service/impl/SeckillServiceImpl.java | 526 ++++++++++++++++++ .../module/seckill/vo/SeckillActivityVO.java | 50 ++ .../module/seckill/vo/SeckillProductVO.java | 57 ++ .../module/seckill/vo/SeckillResultVO.java | 29 + .../src/main/resources/application.yml | 31 +- .../src/main/resources/lua/seckill.lua | 42 ++ 41 files changed, 2589 insertions(+), 27 deletions(-) create mode 100644 admin-snack/src/components/business/ImageUploader.vue create mode 100644 server-snack/src/main/java/com/snack/server/common/storage/FileStorage.java create mode 100644 server-snack/src/main/java/com/snack/server/common/storage/FileStoreResult.java create mode 100644 server-snack/src/main/java/com/snack/server/common/storage/LocalFileStorage.java create mode 100644 server-snack/src/main/java/com/snack/server/common/storage/S3FileStorage.java create mode 100644 server-snack/src/main/java/com/snack/server/common/storage/S3StorageProperties.java create mode 100644 server-snack/src/main/java/com/snack/server/common/utils/SpringContextHolder.java create mode 100644 server-snack/src/main/java/com/snack/server/module/admin/controller/AdminAvatarController.java create mode 100644 server-snack/src/main/java/com/snack/server/module/file/controller/FileController.java create mode 100644 server-snack/src/main/java/com/snack/server/module/file/entity/UploadFile.java create mode 100644 server-snack/src/main/java/com/snack/server/module/file/enums/FileBizTypeEnum.java create mode 100644 server-snack/src/main/java/com/snack/server/module/file/mapper/UploadFileMapper.java create mode 100644 server-snack/src/main/java/com/snack/server/module/file/service/FileService.java create mode 100644 server-snack/src/main/java/com/snack/server/module/file/service/impl/FileServiceImpl.java create mode 100644 server-snack/src/main/java/com/snack/server/module/file/vo/FileUploadVO.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/controller/SeckillAdminController.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/controller/SeckillController.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/dto/req/SeckillActivitySaveReq.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/dto/req/SeckillBuyReq.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/dto/req/SeckillProductAddReq.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/entity/SeckillActivity.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/entity/SeckillOrder.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/entity/SeckillProduct.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/enums/SeckillActivityStatusEnum.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/enums/SeckillResultEnum.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/mapper/SeckillActivityMapper.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/mapper/SeckillOrderMapper.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/mapper/SeckillProductMapper.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/service/SeckillService.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/service/impl/SeckillServiceImpl.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/vo/SeckillActivityVO.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/vo/SeckillProductVO.java create mode 100644 server-snack/src/main/java/com/snack/server/module/seckill/vo/SeckillResultVO.java create mode 100644 server-snack/src/main/resources/lua/seckill.lua diff --git a/admin-snack/src/api/file.ts b/admin-snack/src/api/file.ts index f953e80..a01592d 100644 --- a/admin-snack/src/api/file.ts +++ b/admin-snack/src/api/file.ts @@ -2,15 +2,57 @@ import { request } from '@/utils/request' /** * 通用文件上传 + * + * @param file 要上传的文件 + * @param bizType 业务类型:avatar / admin-avatar / product / sku / banner / notice / chat / common + * @returns 上传结果(含 url/path/id) */ -export function uploadFileApi(file: File, folder = 'common') { +export function uploadFileApi(file: File, bizType = 'common') { const formData = new FormData() formData.append('file', file) - formData.append('folder', folder) - return request<{ url: string; name: string; size: number }>({ - url: '/api/admin/file/upload', + return request<{ + id: number + name: string + url: string + path: string + size: number + contentType: string + bizType: string + createTime: string + }>({ + url: '/api/file/upload', method: 'POST', + params: { bizType }, data: formData, + // 注意:axios 上传文件时不要手动设 Content-Type,让浏览器自动加 boundary headers: { 'Content-Type': 'multipart/form-data' } }) } + +/** 管理员头像上传(带登录态) */ +export function uploadAdminAvatarApi(file: File) { + return uploadFileApi(file, 'admin-avatar') +} + +/** 商品图片上传 */ +export function uploadProductImageApi(file: File) { + return uploadFileApi(file, 'product') +} + +/** 分类图标上传 */ +export function uploadCategoryIconApi(file: File) { + return uploadFileApi(file, 'category') +} + +/** 轮播图上传 */ +export function uploadBannerImageApi(file: File) { + return uploadFileApi(file, 'banner') +} + +/** 删除文件 */ +export function deleteFileApi(id: number) { + return request({ + url: `/api/file/${id}`, + method: 'DELETE' + }) +} diff --git a/admin-snack/src/components/business/ImageUploader.vue b/admin-snack/src/components/business/ImageUploader.vue new file mode 100644 index 0000000..6edff7f --- /dev/null +++ b/admin-snack/src/components/business/ImageUploader.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/admin-snack/src/views/product/list.vue b/admin-snack/src/views/product/list.vue index 8432fd2..394f000 100644 --- a/admin-snack/src/views/product/list.vue +++ b/admin-snack/src/views/product/list.vue @@ -52,6 +52,8 @@ import type { StockAdjustPayload } from '@/api/product' import { getCategoryTreeApi, getCategoryOptionsApi } from '@/api/product' +import ImageUploader from '@/components/business/ImageUploader.vue' +import { Plus } from '@element-plus/icons-vue' import type { CategoryOption } from '@/api/product' import { formatDate } from '@/utils/date' import SearchBar from '@/components/common/SearchBar.vue' @@ -183,8 +185,8 @@ interface FormState { categoryId: number | undefined brand: string mainImage: string - /** 副图:换行分隔的 URL 列表(保存时转 JSON 数组字符串) */ - subImagesText: string + /** 副图:URL 列表(保存时由前端序列化为 JSON 数组字符串) */ + subImages: string[] detail: string originPrice: number | undefined status: 0 | 1 @@ -202,7 +204,7 @@ const form = reactive({ categoryId: undefined, brand: '', mainImage: '', - subImagesText: '', + subImages: [], detail: '', originPrice: undefined, status: 1, @@ -211,6 +213,21 @@ const form = reactive({ skuList: [emptySku()] }) +/** 副图独立 state 方便多图 UI 操作 */ +const subImagesList = ref([]) + +function addSubImage() { + subImagesList.value.push('') +} + +function updateSubImage(idx: number, url: string | undefined) { + if (url) { + subImagesList.value[idx] = url + } else { + subImagesList.value.splice(idx, 1) + } +} + const rules = computed>(() => ({ name: [ { required: true, message: '请输入商品名称', trigger: 'blur' }, @@ -262,7 +279,7 @@ async function openCreate() { categoryId: undefined, brand: '', mainImage: '', - subImagesText: '', + subImages: [], detail: '', originPrice: undefined, status: 1, @@ -270,6 +287,7 @@ async function openCreate() { isNew: 0, skuList: [emptySku()] }) + subImagesList.value = [] dialogVisible.value = true } @@ -284,13 +302,14 @@ async function openEdit(row: ProductItem) { detail = null } const d = detail ?? (row as unknown as ProductDetail) + const subImgs: string[] = d.subImages || [] Object.assign(form, { id: d.id, name: d.name ?? row.name, categoryId: d.categoryId ?? row.categoryId, brand: d.brand ?? '', mainImage: d.mainImage ?? row.mainImage ?? '', - subImagesText: (d.subImages || []).join('\n'), + subImages: subImgs, detail: d.detail ?? '', originPrice: d.originPrice, status: (d.status ?? row.status) as 0 | 1, @@ -309,6 +328,7 @@ async function openEdit(row: ProductItem) { })) : [emptySku()] }) + subImagesList.value = [...subImgs] dialogVisible.value = true } @@ -318,11 +338,8 @@ async function handleSubmit() { if (!valid) return submitting.value = true try { - // 副图:换行 / 空白分隔的 URL 串 → JSON 数组字符串 - const subImagesArr = form.subImagesText - .split(/[\n,]/g) - .map((s) => s.trim()) - .filter((s) => s.length > 0) + // 副图:从独立 state 取(ImageUploader 直接操作) + const subImagesArr = subImagesList.value.filter((s) => !!s) const payload: ProductSavePayload = { id: form.id, name: form.name, @@ -626,20 +643,35 @@ watch(categoryOptions, (v) => { - - + + - - - - +
+
+ +
+ + 添加副图 + +
+
{