feat(module/seckill): 限时抢购 Redis Lua 防超卖 + 一人一单兜底
- 实体 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 物理文件
This commit is contained in:
parent
a18e4df8a8
commit
c9d85b0815
|
|
@ -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<void>({
|
||||
url: `/api/file/${id}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用图片上传组件
|
||||
* 内部调用 /api/file/upload,组件只关心 url 即可
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, Loading, Picture } from '@element-plus/icons-vue'
|
||||
import { ElMessage, type UploadProps } from 'element-plus'
|
||||
import { uploadFileApi, type FileUploadResult } from '@/api/file'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string // 已有的 URL(编辑时回显)
|
||||
bizType?: string // 业务类型
|
||||
listType?: 'text' | 'picture' | 'picture-card' // el-upload 的展示类型
|
||||
maxSize?: number // 单文件大小限制(MB)
|
||||
accept?: string // 接受的文件类型
|
||||
disabled?: boolean
|
||||
disabledTip?: string // 禁用时的提示
|
||||
// 返回新 URL 的事件
|
||||
// (update:modelValue) 已由 v-model 自动处理
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
bizType: 'common',
|
||||
listType: 'picture-card',
|
||||
maxSize: 5,
|
||||
accept: 'image/*',
|
||||
disabled: false,
|
||||
disabledTip: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: string | undefined): void
|
||||
(e: 'change', result: FileUploadResult): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const fileUrl = ref<string | undefined>(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
fileUrl.value = v
|
||||
})
|
||||
|
||||
const headers = {
|
||||
// 上传时让 axios 自动加 Content-Type + boundary,这里其实可以不设
|
||||
}
|
||||
|
||||
/** 拦截 el-upload 自动上传,改为手动调接口(更可控) */
|
||||
const httpRequest: UploadProps['httpRequest'] = async (options) => {
|
||||
const file = options.file as File
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB`)
|
||||
options.onError({ status: 1, message: 'file too large' } as any)
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await uploadFileApi(file, props.bizType)
|
||||
fileUrl.value = res.url
|
||||
emit('update:modelValue', res.url)
|
||||
emit('change', res)
|
||||
ElMessage.success('上传成功')
|
||||
options.onSuccess(res)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('上传失败:' + (e?.message || '未知错误'))
|
||||
options.onError(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
fileUrl.value = undefined
|
||||
emit('update:modelValue', undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image-uploader">
|
||||
<el-upload
|
||||
v-if="!disabled"
|
||||
:show-file-list="false"
|
||||
:list-type="listType"
|
||||
:http-request="httpRequest"
|
||||
:before-upload="beforeUpload"
|
||||
:accept="accept"
|
||||
:headers="headers"
|
||||
class="uploader-trigger"
|
||||
>
|
||||
<template v-if="fileUrl">
|
||||
<img v-if="listType !== 'text'" :src="fileUrl" class="preview-image" />
|
||||
<el-button v-else type="primary" :icon="Picture">已上传,点击重新上传</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon v-if="loading" class="is-loading"><Loading /></el-icon>
|
||||
<el-icon v-else><Plus /></el-icon>
|
||||
<div v-if="listType !== 'text'" class="upload-text">点击上传</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<!-- 禁用态:只读预览 -->
|
||||
<div v-else class="disabled-preview">
|
||||
<img v-if="fileUrl" :src="fileUrl" class="preview-image" />
|
||||
<el-empty v-else description="暂无图片" :image-size="60" />
|
||||
<div v-if="disabledTip" class="disabled-tip">{{ disabledTip }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 已上传时显示删除按钮(仅非 disabled) -->
|
||||
<div v-if="!disabled && fileUrl" class="actions">
|
||||
<el-button text type="danger" size="small" @click="handleRemove">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.uploader-trigger {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
background: $bg-page;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: $brand-primary;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-upload--picture-card) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
background: $bg-page;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<FormState>({
|
|||
categoryId: undefined,
|
||||
brand: '',
|
||||
mainImage: '',
|
||||
subImagesText: '',
|
||||
subImages: [],
|
||||
detail: '',
|
||||
originPrice: undefined,
|
||||
status: 1,
|
||||
|
|
@ -211,6 +213,21 @@ const form = reactive<FormState>({
|
|||
skuList: [emptySku()]
|
||||
})
|
||||
|
||||
/** 副图独立 state 方便多图 UI 操作 */
|
||||
const subImagesList = ref<string[]>([])
|
||||
|
||||
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<FormRules<FormState>>(() => ({
|
||||
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) => {
|
|||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="主图 URL">
|
||||
<el-input v-model="form.mainImage" placeholder="https://..." />
|
||||
<el-form-item label="主图">
|
||||
<ImageUploader v-model="form.mainImage" biz-type="product" :max-size="5" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="副图">
|
||||
<el-input
|
||||
v-model="form.subImagesText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="每行一个 URL,或用逗号分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="商品详情">
|
||||
<div class="sub-images">
|
||||
<div
|
||||
v-for="(img, idx) in subImagesList"
|
||||
:key="idx"
|
||||
class="sub-image-item"
|
||||
>
|
||||
<ImageUploader
|
||||
:model-value="img"
|
||||
biz-type="product"
|
||||
:max-size="5"
|
||||
@update:modelValue="(v) => updateSubImage(idx, v)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="subImagesList.length < 5"
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Plus"
|
||||
@click="addSubImage"
|
||||
>
|
||||
添加副图
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item> <el-form-item label="商品详情">
|
||||
<el-input
|
||||
v-model="form.detail"
|
||||
type="textarea"
|
||||
|
|
@ -767,6 +799,16 @@ watch(categoryOptions, (v) => {
|
|||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sub-image-item {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
.product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package com.snack.server.common.storage;
|
||||
|
||||
/**
|
||||
* 文件存储接口(策略模式)
|
||||
*
|
||||
* 业务层只依赖此接口,不关心文件存在哪。
|
||||
* 默认实现:LocalFileStorage(本地 ./uploads/)
|
||||
* 后续可扩展:QiniuFileStorage / AliyunOssFileStorage / MinioFileStorage
|
||||
*/
|
||||
public interface FileStorage {
|
||||
|
||||
/**
|
||||
* 存储文件
|
||||
*
|
||||
* @param bytes 文件字节
|
||||
* @param originalName 原始文件名(含扩展名)
|
||||
* @param bizType 业务类型(avatar/product/banner 等)
|
||||
* @return 存储结果
|
||||
*/
|
||||
FileStoreResult store(byte[] bytes, String originalName, String bizType);
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* @param path 存储路径(不含域名)
|
||||
*/
|
||||
void delete(String path);
|
||||
|
||||
/**
|
||||
* 通过相对路径获取完整访问 URL
|
||||
*/
|
||||
String getAccessUrl(String path);
|
||||
|
||||
/**
|
||||
* 从完整 URL 反查存储路径(用于删除时定位文件)
|
||||
* @param url 完整 URL(http://...)
|
||||
* @return 相对 path;null 表示无法解析
|
||||
*/
|
||||
String parsePathFromUrl(String url);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.snack.server.common.storage;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 文件存储结果
|
||||
*/
|
||||
@Data
|
||||
public class FileStoreResult implements Serializable {
|
||||
|
||||
/** 存储路径(不含域名前缀,相对 ./uploads/) */
|
||||
private String path;
|
||||
|
||||
/** 访问 URL(含域名前缀) */
|
||||
private String url;
|
||||
|
||||
/** 原始文件名 */
|
||||
private String name;
|
||||
|
||||
/** 存储后的文件名(含 UUID 前缀) */
|
||||
private String storedName;
|
||||
|
||||
/** 文件大小(字节) */
|
||||
private Long size;
|
||||
|
||||
/** MIME 类型 */
|
||||
private String contentType;
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package com.snack.server.common.storage;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 本地文件存储实现
|
||||
*
|
||||
* 文件保存路径:./uploads/{bizType}/{yyyy-MM}/{uuid}.{ext}
|
||||
* 访问路径:http://host:port/uploads/...
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "snack.storage.type", havingValue = "local", matchIfMissing = true)
|
||||
public class LocalFileStorage implements FileStorage {
|
||||
|
||||
/** 上传根目录(相对当前进程) */
|
||||
@Value("${snack.upload.path:./uploads/}")
|
||||
private String uploadPath;
|
||||
|
||||
/** 访问域名前缀 */
|
||||
@Value("${snack.upload.domain:http://localhost:8080/uploads/}")
|
||||
private String domain;
|
||||
|
||||
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
private static final DateTimeFormatter DATE_DIR_FMT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
@Override
|
||||
public FileStoreResult store(byte[] bytes, String originalName, String bizType) {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
throw new IllegalArgumentException("文件内容为空");
|
||||
}
|
||||
if (bytes.length > MAX_FILE_SIZE) {
|
||||
throw new IllegalArgumentException("文件大小不能超过 " + (MAX_FILE_SIZE / 1024 / 1024) + "MB");
|
||||
}
|
||||
String ext = StrUtil.subAfter(originalName, '.', true);
|
||||
if (StrUtil.isBlank(ext)) {
|
||||
ext = "bin";
|
||||
}
|
||||
ext = ext.toLowerCase();
|
||||
|
||||
// 按 "bizType/yyyy-MM/" 分目录
|
||||
String biz = StrUtil.blankToDefault(bizType, "common");
|
||||
String monthDir = LocalDate.now().format(DATE_DIR_FMT);
|
||||
String relativeDir = biz + "/" + monthDir;
|
||||
Path dir = Paths.get(uploadPath, relativeDir).toAbsolutePath();
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
} catch (IOException e) {
|
||||
log.error("创建上传目录失败 {}", dir, e);
|
||||
throw new RuntimeException("创建上传目录失败", e);
|
||||
}
|
||||
|
||||
// UUID 文件名
|
||||
String storedName = IdUtil.fastSimpleUUID() + "." + ext;
|
||||
File target = new File(dir.toFile(), storedName);
|
||||
try {
|
||||
FileUtil.writeBytes(target, bytes);
|
||||
} catch (Exception e) {
|
||||
log.error("写入文件失败 {}", target.getAbsolutePath(), e);
|
||||
throw new RuntimeException("写入文件失败", e);
|
||||
}
|
||||
|
||||
FileStoreResult result = new FileStoreResult();
|
||||
String relativePath = relativeDir + "/" + storedName;
|
||||
result.setPath(relativePath);
|
||||
result.setStoredName(storedName);
|
||||
result.setUrl(domain + relativePath);
|
||||
result.setName(originalName);
|
||||
result.setSize((long) bytes.length);
|
||||
result.setContentType(detectContentType(ext));
|
||||
log.info("文件已存储 path={} size={}B", relativePath, bytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
if (StrUtil.isBlank(path)) return;
|
||||
File f = new File(uploadPath, path);
|
||||
if (f.exists() && f.delete()) {
|
||||
log.info("删除文件 {}", path);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAccessUrl(String path) {
|
||||
if (StrUtil.isBlank(path)) return null;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path; // 已经是完整 URL
|
||||
}
|
||||
return domain + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从完整 URL 反查 path
|
||||
*/
|
||||
public String parsePathFromUrl(String url) {
|
||||
if (StrUtil.isBlank(url)) return null;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return url; // 已经是 path
|
||||
}
|
||||
int idx = url.indexOf("/uploads/");
|
||||
if (idx == -1) return null;
|
||||
return url.substring(idx + "/uploads/".length());
|
||||
}
|
||||
|
||||
private String detectContentType(String ext) {
|
||||
return switch (ext) {
|
||||
case "jpg", "jpeg" -> "image/jpeg";
|
||||
case "png" -> "image/png";
|
||||
case "gif" -> "image/gif";
|
||||
case "webp" -> "image/webp";
|
||||
case "pdf" -> "application/pdf";
|
||||
case "mp4" -> "video/mp4";
|
||||
default -> "application/octet-stream";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package com.snack.server.common.storage;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.BucketAlreadyOwnedByYouException;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* S3 协议对象存储实现
|
||||
*
|
||||
* 支持:RustFS / MinIO / AWS S3 / 阿里云 OSS(S3 兼容模式)
|
||||
* 仅当 application.yml 中 snack.storage.type=s3 时被实例化
|
||||
*
|
||||
* 存储路径:{bucket}/{bizType}/{yyyy-MM}/{uuid}.{ext}
|
||||
* 访问路径:{publicDomain}/{bizType}/{yyyy-MM}/{uuid}.{ext}
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "snack.storage.type", havingValue = "s3")
|
||||
public class S3FileStorage implements FileStorage, DisposableBean {
|
||||
|
||||
private final S3Client s3Client;
|
||||
private final S3StorageProperties props;
|
||||
private final String publicDomain;
|
||||
|
||||
public S3FileStorage(S3StorageProperties props) {
|
||||
this.props = props;
|
||||
if (StrUtil.isBlank(props.getEndpoint())
|
||||
|| StrUtil.isBlank(props.getAccessKey())
|
||||
|| StrUtil.isBlank(props.getBucket())) {
|
||||
throw new IllegalStateException(
|
||||
"S3 存储配置不完整:请在 application.yml 配置 snack.s3.{endpoint,access-key,secret-key,bucket}");
|
||||
}
|
||||
this.s3Client = S3Client.builder()
|
||||
.endpoint(URI.create(props.getEndpoint()))
|
||||
.region(Region.of(props.getRegion()))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(props.getAccessKey(), props.getSecretKey())))
|
||||
.serviceConfiguration(S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(props.getPathStyleAccess())
|
||||
.build())
|
||||
.build();
|
||||
// 启动时确保 bucket 存在
|
||||
ensureBucket();
|
||||
// 公网访问域名前缀
|
||||
this.publicDomain = StrUtil.removeSuffix(
|
||||
StrUtil.blankToDefault(props.getPublicDomain(),
|
||||
props.getEndpoint() + "/" + props.getBucket()),
|
||||
"/");
|
||||
log.info("[S3 存储] 已初始化 endpoint={} bucket={} publicDomain={}",
|
||||
props.getEndpoint(), props.getBucket(), publicDomain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并自动创建 bucket
|
||||
*/
|
||||
private void ensureBucket() {
|
||||
try {
|
||||
s3Client.headBucket(HeadBucketRequest.builder()
|
||||
.bucket(props.getBucket())
|
||||
.build());
|
||||
log.info("S3 bucket 已存在:{}", props.getBucket());
|
||||
} catch (BucketAlreadyOwnedByYouException e) {
|
||||
// ignore
|
||||
} catch (Exception e) {
|
||||
// 大多数服务端对 headBucket 的 404 抛 NoSuchBucket,尝试创建
|
||||
try {
|
||||
s3Client.createBucket(CreateBucketRequest.builder()
|
||||
.bucket(props.getBucket())
|
||||
.build());
|
||||
log.info("S3 bucket 已创建:{}", props.getBucket());
|
||||
} catch (BucketAlreadyOwnedByYouException ignore) {
|
||||
// 并发场景
|
||||
} catch (Exception ex) {
|
||||
log.warn("S3 bucket 检查 / 创建失败(可能为只读账号):{}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileStoreResult store(byte[] bytes, String originalName, String bizType) {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
throw new IllegalArgumentException("文件内容为空");
|
||||
}
|
||||
String ext = StrUtil.subAfter(originalName, '.', true);
|
||||
if (StrUtil.isBlank(ext)) ext = "bin";
|
||||
ext = ext.toLowerCase();
|
||||
|
||||
String biz = StrUtil.blankToDefault(bizType, "common");
|
||||
String monthDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
String relativeDir = biz + "/" + monthDir;
|
||||
String storedName = IdUtil.fastSimpleUUID() + "." + ext;
|
||||
String key = relativeDir + "/" + storedName;
|
||||
|
||||
PutObjectRequest putReq = PutObjectRequest.builder()
|
||||
.bucket(props.getBucket())
|
||||
.key(key)
|
||||
.contentType(detectContentType(ext))
|
||||
.contentLength((long) bytes.length)
|
||||
.build();
|
||||
s3Client.putObject(putReq, RequestBody.fromBytes(bytes));
|
||||
|
||||
FileStoreResult result = new FileStoreResult();
|
||||
result.setPath(key);
|
||||
result.setStoredName(storedName);
|
||||
result.setUrl(publicDomain + "/" + key);
|
||||
result.setName(originalName);
|
||||
result.setSize((long) bytes.length);
|
||||
result.setContentType(detectContentType(ext));
|
||||
log.info("[S3 存储] 上传成功 bucket={} key={} size={}",
|
||||
props.getBucket(), key, bytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
if (StrUtil.isBlank(path)) return;
|
||||
// 兼容 path = URL 的情况
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
path = parsePathFromUrl(path);
|
||||
if (path == null) return;
|
||||
}
|
||||
try {
|
||||
s3Client.deleteObject(DeleteObjectRequest.builder()
|
||||
.bucket(props.getBucket())
|
||||
.key(path)
|
||||
.build());
|
||||
log.info("[S3 存储] 删除成功 key={}", path);
|
||||
} catch (Exception e) {
|
||||
log.error("[S3 存储] 删除失败 key={}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAccessUrl(String path) {
|
||||
if (StrUtil.isBlank(path)) return null;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path;
|
||||
}
|
||||
return publicDomain + "/" + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 反查 S3 key
|
||||
*/
|
||||
public String parsePathFromUrl(String url) {
|
||||
if (url == null) return null;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return url;
|
||||
}
|
||||
// 去掉 publicDomain 前缀
|
||||
if (publicDomain != null && url.startsWith(publicDomain)) {
|
||||
return url.substring(publicDomain.length() + 1);
|
||||
}
|
||||
// 兜底:去掉 endpoint + bucket
|
||||
String prefix = props.getEndpoint() + "/" + props.getBucket() + "/";
|
||||
if (url.startsWith(prefix)) {
|
||||
return url.substring(prefix.length());
|
||||
}
|
||||
// 最后兜底:取 bucket 之后的部分
|
||||
int idx = url.indexOf(props.getBucket() + "/");
|
||||
if (idx != -1) {
|
||||
return url.substring(idx + props.getBucket().length() + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String detectContentType(String ext) {
|
||||
return switch (ext) {
|
||||
case "jpg", "jpeg" -> "image/jpeg";
|
||||
case "png" -> "image/png";
|
||||
case "gif" -> "image/gif";
|
||||
case "webp" -> "image/webp";
|
||||
case "pdf" -> "application/pdf";
|
||||
case "mp4" -> "video/mp4";
|
||||
default -> "application/octet-stream";
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
if (s3Client != null) {
|
||||
s3Client.close();
|
||||
log.info("[S3 存储] S3Client 已关闭");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.snack.server.common.storage;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* S3 / 对象存储配置
|
||||
*
|
||||
* 对应 application.yml 中 snack.s3.*
|
||||
* 适用于:RustFS / MinIO / AWS S3 / 阿里云 OSS(S3 协议)
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "snack.s3")
|
||||
public class S3StorageProperties {
|
||||
|
||||
/** 服务端点(含协议,不含 bucket) */
|
||||
private String endpoint;
|
||||
|
||||
/** 区域 */
|
||||
private String region = "us-east-1";
|
||||
|
||||
/** 是否路径风格(MinIO / RustFS=true,AWS S3=false) */
|
||||
private Boolean pathStyleAccess = true;
|
||||
|
||||
/** AccessKey */
|
||||
private String accessKey;
|
||||
|
||||
/** SecretKey */
|
||||
private String secretKey;
|
||||
|
||||
/** 存储桶 */
|
||||
private String bucket;
|
||||
|
||||
/** 公网访问域名前缀(含 bucket) */
|
||||
private String publicDomain;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.snack.server.common.utils;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Spring 上下文工具:用于在非 Spring 管理的代码中获取 Bean
|
||||
*
|
||||
* 仅推荐在工具类、跨模块场景使用;业务代码请用 @Autowired 注入。
|
||||
*/
|
||||
@Component
|
||||
public class SpringContextHolder implements ApplicationContextAware {
|
||||
|
||||
private static ApplicationContext context;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
SpringContextHolder.context = applicationContext;
|
||||
}
|
||||
|
||||
public static <T> T getBean(Class<T> clazz) {
|
||||
if (context == null) {
|
||||
throw new IllegalStateException("Spring 上下文未初始化");
|
||||
}
|
||||
return context.getBean(clazz);
|
||||
}
|
||||
|
||||
public static Object getBean(String name) {
|
||||
return context.getBean(name);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,4 +30,18 @@ public interface RedisKey {
|
|||
|
||||
/** 客服 WebSocket Session 索引 */
|
||||
String CHAT_WS_ADMIN = "chat:ws:admin:%d";
|
||||
|
||||
// ==================== 抢购模块 ====================
|
||||
|
||||
/** 抢购商品库存:seckill:stock:{activityId}:{skuId} */
|
||||
String SECKILL_STOCK = "seckill:stock:%d:%d";
|
||||
|
||||
/** 用户抢购记录:seckill:user:{userId}:{activityId}:{skuId}(限购 N 件时存数量) */
|
||||
String SECKILL_USER_RECORD = "seckill:user:%d:%d:%d";
|
||||
|
||||
/** 抢购结果:seckill:result:{orderNo}(0=排队中 1=成功 2=失败) */
|
||||
String SECKILL_RESULT = "seckill:result:%s";
|
||||
|
||||
/** 活动预热标记:seckill:warmup:{activityId}(1 表示 Redis 库存已同步) */
|
||||
String SECKILL_WARMUP = "seckill:warmup:%d";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.snack.server.module.admin.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.file.enums.FileBizTypeEnum;
|
||||
import com.snack.server.module.file.service.FileService;
|
||||
import com.snack.server.module.file.vo.FileUploadVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 管理员头像上传
|
||||
*/
|
||||
@Tag(name = "管理员头像")
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/avatar")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminAvatarController {
|
||||
|
||||
private final FileService fileService;
|
||||
|
||||
@Operation(summary = "上传当前管理员头像")
|
||||
@SaCheckLogin
|
||||
@PostMapping("/upload")
|
||||
public Result<FileUploadVO> upload(@RequestParam("file") MultipartFile file) {
|
||||
Long uploaderId = cn.dev33.satoken.stp.StpUtil.getLoginIdAsLong();
|
||||
return Result.ok(fileService.uploadWithUploader(
|
||||
file, FileBizTypeEnum.ADMIN_AVATAR.getCode(), uploaderId));
|
||||
}
|
||||
}
|
||||
|
|
@ -101,4 +101,19 @@ public class CategoryAdminController {
|
|||
categoryService.updateSort(id, sort);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "上传分类图标")
|
||||
@SaCheckLogin
|
||||
@PostMapping("/{id}/icon")
|
||||
public Result<com.snack.server.module.file.vo.FileUploadVO> uploadIcon(
|
||||
@Parameter(description = "分类 ID") @PathVariable Long id,
|
||||
@org.springframework.web.bind.annotation.RequestParam("file") org.springframework.web.multipart.MultipartFile file) {
|
||||
com.snack.server.module.file.enums.FileBizTypeEnum biz = com.snack.server.module.file.enums.FileBizTypeEnum.CATEGORY;
|
||||
com.snack.server.module.file.vo.FileUploadVO vo =
|
||||
com.snack.server.common.utils.SpringContextHolder.getBean(com.snack.server.module.file.service.FileService.class)
|
||||
.uploadWithUploader(file, biz.getCode(), null);
|
||||
// 同步更新分类的 icon 字段
|
||||
categoryService.updateIcon(id, vo.getUrl());
|
||||
return Result.ok(vo);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,4 +58,9 @@ public interface CategoryService {
|
|||
* 调整排序
|
||||
*/
|
||||
void updateSort(Long id, Integer sort);
|
||||
|
||||
/**
|
||||
* 更新分类图标
|
||||
*/
|
||||
void updateIcon(Long id, String iconUrl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,13 @@ public class CategoryServiceImpl implements CategoryService {
|
|||
// }
|
||||
|
||||
categoryMapper.deleteById(id);
|
||||
|
||||
// 删除分类时,关联的物理文件也清理(通过工具类从 URL 反查 path)
|
||||
if (cn.hutool.core.util.StrUtil.isNotBlank(existing.getIcon())) {
|
||||
com.snack.server.common.utils.SpringContextHolder
|
||||
.getBean(com.snack.server.module.file.service.FileService.class)
|
||||
.deleteByPath(existing.getIcon());
|
||||
}
|
||||
log.info("删除分类 id={} name={}", id, existing.getName());
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +240,25 @@ public class CategoryServiceImpl implements CategoryService {
|
|||
categoryMapper.updateById(update);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateIcon(Long id, String iconUrl) {
|
||||
if (id == null) {
|
||||
throw new BusinessException(1000, "ID 不能为空");
|
||||
}
|
||||
if (cn.hutool.core.util.StrUtil.isBlank(iconUrl)) {
|
||||
throw new BusinessException(1000, "图标 URL 不能为空");
|
||||
}
|
||||
Category existing = categoryMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(1010, "分类不存在");
|
||||
}
|
||||
Category update = new Category();
|
||||
update.setId(id);
|
||||
update.setIcon(iconUrl);
|
||||
categoryMapper.updateById(update);
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
package com.snack.server.module.file.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.file.service.FileService;
|
||||
import com.snack.server.module.file.vo.FileUploadVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 文件上传(管理端 + 用户端通用)
|
||||
*/
|
||||
@Tag(name = "文件上传")
|
||||
@RestController
|
||||
@RequestMapping("/api/file")
|
||||
@RequiredArgsConstructor
|
||||
public class FileController {
|
||||
|
||||
private final FileService fileService;
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* multipart/form-data,字段名 file
|
||||
*/
|
||||
@Operation(summary = "上传文件(支持图片/通用文件)")
|
||||
@SaCheckLogin
|
||||
@PostMapping("/upload")
|
||||
public Result<FileUploadVO> upload(
|
||||
@Parameter(description = "文件") @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "业务类型:avatar / product / banner / notice / chat / common")
|
||||
@RequestParam(defaultValue = "common") String bizType) {
|
||||
return Result.ok(fileService.upload(file, bizType));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除文件")
|
||||
@SaCheckLogin
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@Parameter(description = "文件 ID") @PathVariable Long id) {
|
||||
fileService.deleteFile(id);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.snack.server.module.file.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 文件上传记录
|
||||
*
|
||||
* 对应数据库表:upload_file
|
||||
*/
|
||||
@Data
|
||||
@TableName("upload_file")
|
||||
public class UploadFile implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 原始文件名 */
|
||||
private String name;
|
||||
|
||||
/** 存储路径(不含域名前缀) */
|
||||
private String path;
|
||||
|
||||
/** 访问 URL */
|
||||
private String url;
|
||||
|
||||
/** 文件大小(字节) */
|
||||
private Long size;
|
||||
|
||||
/** MIME 类型 */
|
||||
private String contentType;
|
||||
|
||||
/** 存储类型:local / qiniu / aliyun / tencent / minio */
|
||||
private String storageType;
|
||||
|
||||
/** 业务类型:avatar / product / banner / chat / notice */
|
||||
private String bizType;
|
||||
|
||||
/** 上传人 ID(用户/管理员) */
|
||||
private Long uploadBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.snack.server.module.file.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 文件业务类型
|
||||
*/
|
||||
@Getter
|
||||
public enum FileBizTypeEnum {
|
||||
|
||||
AVATAR("avatar", "用户头像"),
|
||||
ADMIN_AVATAR("admin-avatar", "管理员头像"),
|
||||
PRODUCT("product", "商品图"),
|
||||
SKU("sku", "SKU 规格图"),
|
||||
CATEGORY("category", "分类图标"),
|
||||
BANNER("banner", "轮播图"),
|
||||
NOTICE("notice", "公告封面"),
|
||||
CHAT("chat", "聊天图片"),
|
||||
COMMON("common", "通用");
|
||||
|
||||
private final String code;
|
||||
private final String desc;
|
||||
|
||||
FileBizTypeEnum(String code, String desc) {
|
||||
this.code = code;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public static FileBizTypeEnum of(String code) {
|
||||
if (code == null) return COMMON;
|
||||
for (FileBizTypeEnum e : values()) {
|
||||
if (e.code.equalsIgnoreCase(code)) return e;
|
||||
}
|
||||
return COMMON;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.snack.server.module.file.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.file.entity.UploadFile;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface UploadFileMapper extends BaseMapper<UploadFile> {
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.snack.server.module.file.service;
|
||||
|
||||
import com.snack.server.module.file.entity.UploadFile;
|
||||
import com.snack.server.module.file.enums.FileBizTypeEnum;
|
||||
import com.snack.server.module.file.vo.FileUploadVO;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 文件上传业务
|
||||
*/
|
||||
public interface FileService {
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param file Spring MultipartFile
|
||||
* @param bizType 业务类型字符串(avatar / product / ...)
|
||||
* @return 上传结果(含访问 URL)
|
||||
*/
|
||||
FileUploadVO upload(MultipartFile file, String bizType);
|
||||
|
||||
/**
|
||||
* 业务上传:自动写库 + 记录上传人
|
||||
*/
|
||||
FileUploadVO uploadWithUploader(MultipartFile file, String bizType, Long uploaderId);
|
||||
|
||||
/**
|
||||
* 删除文件(同时删物理文件 + 数据库记录)
|
||||
*/
|
||||
void deleteFile(Long id);
|
||||
|
||||
/**
|
||||
* 通过路径直接删除物理文件(不查数据库)
|
||||
*/
|
||||
void deleteByPath(String path);
|
||||
|
||||
/**
|
||||
* 查询文件记录
|
||||
*/
|
||||
UploadFile getById(Long id);
|
||||
|
||||
/**
|
||||
* 业务类型枚举
|
||||
*/
|
||||
FileBizTypeEnum resolveBizType(String bizType);
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package com.snack.server.module.file.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.snack.server.common.exception.BusinessException;
|
||||
import com.snack.server.common.storage.FileStorage;
|
||||
import com.snack.server.common.storage.FileStoreResult;
|
||||
import com.snack.server.module.file.entity.UploadFile;
|
||||
import com.snack.server.module.file.enums.FileBizTypeEnum;
|
||||
import com.snack.server.module.file.mapper.UploadFileMapper;
|
||||
import com.snack.server.module.file.service.FileService;
|
||||
import com.snack.server.module.file.vo.FileUploadVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件上传业务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FileServiceImpl implements FileService {
|
||||
|
||||
/** 允许的图片类型 */
|
||||
private static final List<String> IMAGE_EXTS =
|
||||
Arrays.asList("jpg", "jpeg", "png", "gif", "webp");
|
||||
|
||||
private final FileStorage fileStorage;
|
||||
private final UploadFileMapper uploadFileMapper;
|
||||
|
||||
@Override
|
||||
public FileUploadVO upload(MultipartFile file, String bizType) {
|
||||
return uploadWithUploader(file, bizType, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileUploadVO uploadWithUploader(MultipartFile file, String bizType, Long uploaderId) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException(1050, "文件为空");
|
||||
}
|
||||
String originalName = file.getOriginalFilename();
|
||||
if (StrUtil.isBlank(originalName)) {
|
||||
originalName = "unknown";
|
||||
}
|
||||
String ext = StrUtil.subAfter(originalName, '.', true);
|
||||
if (StrUtil.isBlank(ext)) {
|
||||
throw new BusinessException(1051, "无法识别文件类型");
|
||||
}
|
||||
ext = ext.toLowerCase();
|
||||
|
||||
// 图片类型校验
|
||||
FileBizTypeEnum biz = resolveBizType(bizType);
|
||||
if (biz == FileBizTypeEnum.AVATAR || biz == FileBizTypeEnum.ADMIN_AVATAR
|
||||
|| biz == FileBizTypeEnum.PRODUCT || biz == FileBizTypeEnum.SKU
|
||||
|| biz == FileBizTypeEnum.BANNER || biz == FileBizTypeEnum.NOTICE
|
||||
|| biz == FileBizTypeEnum.CATEGORY) {
|
||||
if (!IMAGE_EXTS.contains(ext)) {
|
||||
throw new BusinessException(1051, "仅支持图片格式:" + IMAGE_EXTS);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 存物理文件
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = file.getBytes();
|
||||
} catch (IOException e) {
|
||||
log.error("读取上传文件失败", e);
|
||||
throw new BusinessException(1050, "读取文件失败");
|
||||
}
|
||||
FileStoreResult store = fileStorage.store(bytes, originalName, biz.getCode());
|
||||
|
||||
// 2. 写数据库
|
||||
UploadFile entity = new UploadFile();
|
||||
entity.setName(originalName);
|
||||
entity.setPath(store.getPath());
|
||||
entity.setUrl(store.getUrl());
|
||||
entity.setSize(store.getSize());
|
||||
entity.setContentType(store.getContentType());
|
||||
entity.setStorageType("local");
|
||||
entity.setBizType(biz.getCode());
|
||||
entity.setUploadBy(uploaderId);
|
||||
uploadFileMapper.insert(entity);
|
||||
|
||||
// 3. 返回
|
||||
FileUploadVO vo = new FileUploadVO();
|
||||
BeanUtil.copyProperties(entity, vo);
|
||||
log.info("文件上传成功 id={} bizType={} url={}", entity.getId(), biz, store.getUrl());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteFile(Long id) {
|
||||
UploadFile entity = uploadFileMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new BusinessException(1050, "文件不存在");
|
||||
}
|
||||
fileStorage.delete(entity.getPath());
|
||||
uploadFileMapper.deleteById(id);
|
||||
log.info("删除文件 id={} path={}", id, entity.getPath());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByPath(String path) {
|
||||
if (StrUtil.isBlank(path)) return;
|
||||
// 如果是完整 URL,先反查成 path
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
path = fileStorage.parsePathFromUrl(path);
|
||||
if (path == null) {
|
||||
log.warn("URL 反查 path 失败:{}", path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
fileStorage.delete(path);
|
||||
uploadFileMapper.delete(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UploadFile>()
|
||||
.eq(UploadFile::getPath, path)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UploadFile getById(Long id) {
|
||||
return uploadFileMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileBizTypeEnum resolveBizType(String bizType) {
|
||||
return FileBizTypeEnum.of(bizType);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.snack.server.module.file.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 上传文件返回
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "上传文件返回")
|
||||
public class FileUploadVO implements Serializable {
|
||||
|
||||
@Schema(description = "文件 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "原始文件名")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "访问 URL(前端直接用此字段展示图片)")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "存储路径")
|
||||
private String path;
|
||||
|
||||
@Schema(description = "文件大小(字节)")
|
||||
private Long size;
|
||||
|
||||
@Schema(description = "MIME 类型")
|
||||
private String contentType;
|
||||
|
||||
@Schema(description = "业务类型")
|
||||
private String bizType;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "上传时间")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package com.snack.server.module.seckill.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillActivitySaveReq;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillProductAddReq;
|
||||
import com.snack.server.module.seckill.entity.SeckillActivity;
|
||||
import com.snack.server.module.seckill.service.SeckillService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 抢购管理(管理端)
|
||||
*/
|
||||
@Tag(name = "抢购管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/seckill")
|
||||
@RequiredArgsConstructor
|
||||
public class SeckillAdminController {
|
||||
|
||||
private final SeckillService seckillService;
|
||||
|
||||
@Operation(summary = "活动分页")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/page")
|
||||
public Result<Page<SeckillActivity>> page(
|
||||
@RequestParam(defaultValue = "1") Long current,
|
||||
@RequestParam(defaultValue = "10") Long size,
|
||||
@Parameter(description = "状态过滤") @RequestParam(required = false) Integer status) {
|
||||
return Result.ok(seckillService.pageActivities(current, size, status));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建活动")
|
||||
@SaCheckRole("super_admin")
|
||||
@PostMapping
|
||||
public Result<Long> create(@Valid @RequestBody SeckillActivitySaveReq req) {
|
||||
return Result.ok(seckillService.createActivity(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新活动")
|
||||
@SaCheckRole("super_admin")
|
||||
@PutMapping
|
||||
public Result<Void> update(@Valid @RequestBody SeckillActivitySaveReq req) {
|
||||
seckillService.updateActivity(req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "删除活动(级联清理 Redis)")
|
||||
@SaCheckRole("super_admin")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@Parameter(description = "活动 ID") @PathVariable Long id) {
|
||||
seckillService.deleteActivity(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "手动结束活动")
|
||||
@SaCheckRole("super_admin")
|
||||
@PutMapping("/{id}/end")
|
||||
public Result<Void> forceEnd(@Parameter(description = "活动 ID") @PathVariable Long id) {
|
||||
seckillService.forceEndActivity(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "预热活动(同步库存到 Redis)")
|
||||
@SaCheckRole("super_admin")
|
||||
@PostMapping("/{id}/warmup")
|
||||
public Result<Void> warmup(@Parameter(description = "活动 ID") @PathVariable Long id) {
|
||||
seckillService.warmupActivity(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "给活动添加商品")
|
||||
@SaCheckRole("super_admin")
|
||||
@PostMapping("/product")
|
||||
public Result<Long> addProduct(@Valid @RequestBody SeckillProductAddReq req) {
|
||||
return Result.ok(seckillService.addProduct(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "移除活动商品")
|
||||
@SaCheckRole("super_admin")
|
||||
@DeleteMapping("/product/{id}")
|
||||
public Result<Void> removeProduct(@Parameter(description = "活动商品 ID") @PathVariable Long id) {
|
||||
seckillService.removeProduct(id);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.snack.server.module.seckill.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillBuyReq;
|
||||
import com.snack.server.module.seckill.service.SeckillService;
|
||||
import com.snack.server.module.seckill.vo.SeckillActivityVO;
|
||||
import com.snack.server.module.seckill.vo.SeckillResultVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 抢购(用户端)
|
||||
*/
|
||||
@Tag(name = "限时抢购")
|
||||
@RestController
|
||||
@RequestMapping("/api/seckill")
|
||||
@RequiredArgsConstructor
|
||||
public class SeckillController {
|
||||
|
||||
private final SeckillService seckillService;
|
||||
|
||||
@Operation(summary = "获取进行中 / 即将开始的抢购活动")
|
||||
@GetMapping("/activities")
|
||||
public Result<List<SeckillActivityVO>> activities() {
|
||||
return Result.ok(seckillService.listActiveActivities());
|
||||
}
|
||||
|
||||
@Operation(summary = "活动详情(含商品)")
|
||||
@GetMapping("/activities/{id}")
|
||||
public Result<SeckillActivityVO> detail(@Parameter(description = "活动 ID") @PathVariable Long id) {
|
||||
return Result.ok(seckillService.getActivityDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "抢购下单(核心接口)")
|
||||
@SaCheckLogin
|
||||
@PostMapping("/buy")
|
||||
public Result<Map<String, String>> buy(@Valid @RequestBody SeckillBuyReq req) {
|
||||
String orderNo = seckillService.seckillBuy(req);
|
||||
return Result.ok(Map.of("orderNo", orderNo));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询抢购结果(轮询接口)")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/result/{orderNo}")
|
||||
public Result<SeckillResultVO> result(@Parameter(description = "排队号 orderNo") @PathVariable String orderNo) {
|
||||
return Result.ok(seckillService.queryResult(orderNo));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.snack.server.module.seckill.dto.req;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 创建/更新抢购活动请求(管理端)
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "创建/更新抢购活动")
|
||||
public class SeckillActivitySaveReq implements Serializable {
|
||||
|
||||
@Schema(description = "活动 ID(更新时必填)")
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "活动名称不能为空")
|
||||
@Schema(description = "活动名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "活动封面图")
|
||||
private String cover;
|
||||
|
||||
@NotNull(message = "开始时间不能为空")
|
||||
@Schema(description = "开始时间")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@NotNull(message = "结束时间不能为空")
|
||||
@Schema(description = "结束时间")
|
||||
private LocalDateTime endTime;
|
||||
|
||||
@Schema(description = "活动说明")
|
||||
private String description;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.snack.server.module.seckill.dto.req;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 抢购请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "抢购请求")
|
||||
public class SeckillBuyReq implements Serializable {
|
||||
|
||||
@NotNull(message = "活动 ID 不能为空")
|
||||
@Schema(description = "活动 ID")
|
||||
private Long activityId;
|
||||
|
||||
@NotNull(message = "SKU ID 不能为空")
|
||||
@Schema(description = "商品 SKU ID")
|
||||
private Long skuId;
|
||||
|
||||
@Schema(description = "购买数量(默认 1,受 perLimit 限制)", example = "1")
|
||||
private Integer quantity = 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.snack.server.module.seckill.dto.req;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 添加活动商品请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "添加抢购活动商品")
|
||||
public class SeckillProductAddReq implements Serializable {
|
||||
|
||||
@NotNull(message = "活动 ID 不能为空")
|
||||
@Schema(description = "活动 ID")
|
||||
private Long activityId;
|
||||
|
||||
@NotNull(message = "商品 SKU ID 不能为空")
|
||||
@Schema(description = "商品 SKU ID")
|
||||
private Long skuId;
|
||||
|
||||
@NotNull(message = "抢购价不能为空")
|
||||
@DecimalMin(value = "0.01", message = "抢购价必须大于 0")
|
||||
@Schema(description = "抢购价")
|
||||
private BigDecimal seckillPrice;
|
||||
|
||||
@NotNull(message = "库存不能为空")
|
||||
@Min(value = 1, message = "库存至少为 1")
|
||||
@Schema(description = "抢购总库存")
|
||||
private Integer seckillStock;
|
||||
|
||||
@Min(value = 1, message = "每人限购至少 1")
|
||||
@Schema(description = "每人限购数量", example = "1")
|
||||
private Integer perLimit = 1;
|
||||
|
||||
@Schema(description = "活动内排序")
|
||||
private Integer sort = 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.snack.server.module.seckill.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 限时抢购活动
|
||||
*
|
||||
* 对应数据库表:seckill_activity
|
||||
*/
|
||||
@Data
|
||||
@TableName("seckill_activity")
|
||||
public class SeckillActivity implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 活动名称 */
|
||||
private String name;
|
||||
|
||||
/** 活动封面图 */
|
||||
private String cover;
|
||||
|
||||
/** 活动开始时间 */
|
||||
private LocalDateTime startTime;
|
||||
|
||||
/** 活动结束时间 */
|
||||
private LocalDateTime endTime;
|
||||
|
||||
/** 状态:0-未开始 1-进行中 2-已结束 */
|
||||
private Integer status;
|
||||
|
||||
/** 活动说明 */
|
||||
private String description;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.snack.server.module.seckill.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 抢购成功记录(仅记录成功,失败不入库)
|
||||
*
|
||||
* 对应数据库表:seckill_order
|
||||
* 唯一索引 (user_id, activity_id, sku_id) —— 一人一单兜底
|
||||
*/
|
||||
@Data
|
||||
@TableName("seckill_order")
|
||||
public class SeckillOrder implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 用户 ID */
|
||||
private Long userId;
|
||||
|
||||
/** 活动 ID */
|
||||
private Long activityId;
|
||||
|
||||
/** 商品 SPU ID */
|
||||
private Long productId;
|
||||
|
||||
/** SKU ID */
|
||||
private Long skuId;
|
||||
|
||||
/** 抢购数量 */
|
||||
private Integer quantity;
|
||||
|
||||
/** 成交单价 */
|
||||
private BigDecimal seckillPrice;
|
||||
|
||||
/** 关联主订单 ID(支付/取消后回填) */
|
||||
private Long orderId;
|
||||
|
||||
/** 状态:0-待支付 1-已支付 2-已取消 3-已退款 */
|
||||
private Integer status;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/** 支付时间 */
|
||||
private LocalDateTime payTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.snack.server.module.seckill.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 抢购活动商品
|
||||
*
|
||||
* 对应数据库表:seckill_product
|
||||
*/
|
||||
@Data
|
||||
@TableName("seckill_product")
|
||||
public class SeckillProduct implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属活动 ID */
|
||||
private Long activityId;
|
||||
|
||||
/** 商品 SPU ID */
|
||||
private Long productId;
|
||||
|
||||
/** 商品 SKU ID */
|
||||
private Long skuId;
|
||||
|
||||
/** 抢购价 */
|
||||
private BigDecimal seckillPrice;
|
||||
|
||||
/** 原价(展示用) */
|
||||
private BigDecimal originPrice;
|
||||
|
||||
/** 总库存(活动开始时同步到 Redis) */
|
||||
private Integer seckillStock;
|
||||
|
||||
/** 剩余库存(MySQL 兜底) */
|
||||
private Integer remainStock;
|
||||
|
||||
/** 每人限购数量 */
|
||||
private Integer perLimit;
|
||||
|
||||
/** 已售数量(异步累加) */
|
||||
private Integer sales;
|
||||
|
||||
/** 活动内排序 */
|
||||
private Integer sort;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.snack.server.module.seckill.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 抢购活动状态
|
||||
*/
|
||||
@Getter
|
||||
public enum SeckillActivityStatusEnum {
|
||||
|
||||
NOT_STARTED(0, "未开始"),
|
||||
IN_PROGRESS(1, "进行中"),
|
||||
ENDED(2, "已结束");
|
||||
|
||||
private final Integer code;
|
||||
private final String desc;
|
||||
|
||||
SeckillActivityStatusEnum(Integer code, String desc) {
|
||||
this.code = code;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间计算状态(用于显示态)
|
||||
*/
|
||||
public static Integer calcStatus(java.time.LocalDateTime start, java.time.LocalDateTime end) {
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
if (now.isBefore(start)) return NOT_STARTED.getCode();
|
||||
if (now.isAfter(end)) return ENDED.getCode();
|
||||
return IN_PROGRESS.getCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.snack.server.module.seckill.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 抢购结果
|
||||
*/
|
||||
@Getter
|
||||
public enum SeckillResultEnum {
|
||||
|
||||
PENDING(0, "排队中"),
|
||||
SUCCESS(1, "抢购成功"),
|
||||
FAILED(2, "抢购失败");
|
||||
|
||||
private final Integer code;
|
||||
private final String desc;
|
||||
|
||||
SeckillResultEnum(Integer code, String desc) {
|
||||
this.code = code;
|
||||
this.desc = desc;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.snack.server.module.seckill.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.seckill.entity.SeckillActivity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface SeckillActivityMapper extends BaseMapper<SeckillActivity> {
|
||||
|
||||
/**
|
||||
* 提前结束活动(管理端手动操作)
|
||||
*/
|
||||
@Update("UPDATE seckill_activity SET status = 2 WHERE id = #{id} AND status = 1")
|
||||
int forceEnd(@Param("id") Long id);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.snack.server.module.seckill.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.seckill.entity.SeckillOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Mapper
|
||||
public interface SeckillOrderMapper extends BaseMapper<SeckillOrder> {
|
||||
|
||||
/**
|
||||
* 标记已支付(关联主订单)
|
||||
*/
|
||||
@Update("UPDATE seckill_order SET status = 1, order_id = #{orderId}, pay_time = NOW() " +
|
||||
"WHERE id = #{id} AND status = 0")
|
||||
int markAsPaid(@Param("id") Long id, @Param("orderId") Long orderId);
|
||||
|
||||
/**
|
||||
* 标记已取消(释放 Redis 限购标记时用)
|
||||
*/
|
||||
@Update("UPDATE seckill_order SET status = 2 WHERE id = #{id} AND status = 0")
|
||||
int markAsCancelled(@Param("id") Long id);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.snack.server.module.seckill.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.seckill.entity.SeckillProduct;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface SeckillProductMapper extends BaseMapper<SeckillProduct> {
|
||||
|
||||
/**
|
||||
* 扣减 MySQL 兜底库存(异步消费 MQ 时调用)
|
||||
*/
|
||||
@Update("UPDATE seckill_product SET remain_stock = remain_stock - #{quantity}, sales = sales + #{quantity} " +
|
||||
"WHERE id = #{id} AND remain_stock >= #{quantity}")
|
||||
int decrRemainStock(@Param("id") Long id, @Param("quantity") Integer quantity);
|
||||
|
||||
/**
|
||||
* 释放 MySQL 兜底库存(订单取消 / 退款时调用)
|
||||
*/
|
||||
@Update("UPDATE seckill_product SET remain_stock = remain_stock + #{quantity}, sales = sales - #{quantity} " +
|
||||
"WHERE id = #{id}")
|
||||
int incrRemainStock(@Param("id") Long id, @Param("quantity") Integer quantity);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.snack.server.module.seckill.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillActivitySaveReq;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillBuyReq;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillProductAddReq;
|
||||
import com.snack.server.module.seckill.entity.SeckillActivity;
|
||||
import com.snack.server.module.seckill.vo.SeckillActivityVO;
|
||||
import com.snack.server.module.seckill.vo.SeckillResultVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 抢购业务接口
|
||||
*/
|
||||
public interface SeckillService {
|
||||
|
||||
// ==================== 用户端 ====================
|
||||
|
||||
/**
|
||||
* 获取进行中 / 即将开始的抢购活动
|
||||
*/
|
||||
List<SeckillActivityVO> listActiveActivities();
|
||||
|
||||
/**
|
||||
* 获取活动详情(含商品)
|
||||
*/
|
||||
SeckillActivityVO getActivityDetail(Long activityId);
|
||||
|
||||
/**
|
||||
* 抢购(核心流程)
|
||||
* @return 排队号 orderNo,前端轮询用
|
||||
*/
|
||||
String seckillBuy(SeckillBuyReq req);
|
||||
|
||||
/**
|
||||
* 查询抢购结果(轮询接口)
|
||||
*/
|
||||
SeckillResultVO queryResult(String orderNo);
|
||||
|
||||
// ==================== 管理端 ====================
|
||||
|
||||
/**
|
||||
* 管理端活动分页
|
||||
*/
|
||||
Page<SeckillActivity> pageActivities(Long current, Long size, Integer status);
|
||||
|
||||
/**
|
||||
* 创建活动
|
||||
*/
|
||||
Long createActivity(SeckillActivitySaveReq req);
|
||||
|
||||
/**
|
||||
* 更新活动
|
||||
*/
|
||||
void updateActivity(SeckillActivitySaveReq req);
|
||||
|
||||
/**
|
||||
* 删除活动
|
||||
*/
|
||||
void deleteActivity(Long id);
|
||||
|
||||
/**
|
||||
* 手动结束活动
|
||||
*/
|
||||
void forceEndActivity(Long id);
|
||||
|
||||
/**
|
||||
* 预热活动(同步库存到 Redis)
|
||||
*/
|
||||
void warmupActivity(Long id);
|
||||
|
||||
/**
|
||||
* 给活动添加商品
|
||||
*/
|
||||
Long addProduct(SeckillProductAddReq req);
|
||||
|
||||
/**
|
||||
* 删除活动商品
|
||||
*/
|
||||
void removeProduct(Long seckillProductId);
|
||||
}
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
package com.snack.server.module.seckill.service.impl;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.common.ResultCode;
|
||||
import com.snack.server.constant.RedisKey;
|
||||
import com.snack.server.exception.BusinessException;
|
||||
import com.snack.server.module.product.entity.Product;
|
||||
import com.snack.server.module.product.entity.ProductSku;
|
||||
import com.snack.server.module.product.mapper.ProductMapper;
|
||||
import com.snack.server.module.product.mapper.ProductSkuMapper;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillActivitySaveReq;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillBuyReq;
|
||||
import com.snack.server.module.seckill.dto.req.SeckillProductAddReq;
|
||||
import com.snack.server.module.seckill.entity.SeckillActivity;
|
||||
import com.snack.server.module.seckill.entity.SeckillOrder;
|
||||
import com.snack.server.module.seckill.entity.SeckillProduct;
|
||||
import com.snack.server.module.seckill.enums.SeckillActivityStatusEnum;
|
||||
import com.snack.server.module.seckill.enums.SeckillResultEnum;
|
||||
import com.snack.server.module.seckill.mapper.SeckillActivityMapper;
|
||||
import com.snack.server.module.seckill.mapper.SeckillOrderMapper;
|
||||
import com.snack.server.module.seckill.mapper.SeckillProductMapper;
|
||||
import com.snack.server.module.seckill.service.SeckillService;
|
||||
import com.snack.server.module.seckill.vo.SeckillActivityVO;
|
||||
import com.snack.server.module.seckill.vo.SeckillProductVO;
|
||||
import com.snack.server.module.seckill.vo.SeckillResultVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.scripting.support.ResourceScriptSource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 抢购业务实现(Redis 防超卖 + MySQL 兜底)
|
||||
*
|
||||
* 关键流程:
|
||||
* 1. 活动预热:管理端开启活动时,把 seckill_stock 同步到 Redis
|
||||
* 2. 用户抢购:Lua 脚本原子完成"限购校验 + 库存扣减"
|
||||
* 3. 写库兜底:seckill_order 唯一索引防一人多单
|
||||
* 4. 异步落库:本示例同步落库,生产应改 MQ
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SeckillServiceImpl implements SeckillService {
|
||||
|
||||
private final SeckillActivityMapper activityMapper;
|
||||
private final SeckillProductMapper productMapper;
|
||||
private final SeckillOrderMapper orderMapper;
|
||||
private final ProductMapper productEntityMapper;
|
||||
private final ProductSkuMapper productSkuMapper;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
/** 抢购限购记录在 Redis 中的过期时间(活动结束后 1 天) */
|
||||
@Value("${snack.seckill.record-ttl-seconds:86400}")
|
||||
private long recordTtl;
|
||||
|
||||
/** Lua 脚本(启动时加载一次) */
|
||||
private final DefaultRedisScript<List> seckillScript = new DefaultRedisScript<>();
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
public void init() {
|
||||
seckillScript.setScriptSource(new ResourceScriptSource(
|
||||
new ClassPathResource("lua/seckill.lua")));
|
||||
seckillScript.setResultType(List.class);
|
||||
}
|
||||
|
||||
// ==================== 用户端 ====================
|
||||
|
||||
@Override
|
||||
public List<SeckillActivityVO> listActiveActivities() {
|
||||
// 查"未结束"的活动:end_time > now
|
||||
List<SeckillActivity> activities = activityMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillActivity>()
|
||||
.gt(SeckillActivity::getEndTime, LocalDateTime.now().minusHours(1)) // 兜底展示刚结束 1 小时的
|
||||
.orderByAsc(SeckillActivity::getStartTime)
|
||||
);
|
||||
if (CollUtil.isEmpty(activities)) return new ArrayList<>();
|
||||
return activities.stream().map(a -> enrichActivityVO(a, false)).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SeckillActivityVO getActivityDetail(Long activityId) {
|
||||
SeckillActivity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) {
|
||||
throw new BusinessException(1000, "活动不存在");
|
||||
}
|
||||
return enrichActivityVO(activity, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 装配活动 VO(含状态计算、倒计时、商品)
|
||||
*/
|
||||
private SeckillActivityVO enrichActivityVO(SeckillActivity activity, boolean withProducts) {
|
||||
SeckillActivityVO vo = new SeckillActivityVO();
|
||||
BeanUtil.copyProperties(activity, vo);
|
||||
// 动态状态
|
||||
Integer status = SeckillActivityStatusEnum.calcStatus(activity.getStartTime(), activity.getEndTime());
|
||||
vo.setStatus(status);
|
||||
vo.setStatusText(SeckillActivityStatusEnum.values()[status].getDesc());
|
||||
// 倒计时(开始前:距开始;进行中:距结束)
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (status == SeckillActivityStatusEnum.NOT_STARTED.getCode()) {
|
||||
vo.setCountdownSeconds(Duration.between(now, activity.getStartTime()).getSeconds());
|
||||
} else if (status == SeckillActivityStatusEnum.IN_PROGRESS.getCode()) {
|
||||
vo.setCountdownSeconds(Duration.between(now, activity.getEndTime()).getSeconds());
|
||||
} else {
|
||||
vo.setCountdownSeconds(0L);
|
||||
}
|
||||
// 商品
|
||||
if (withProducts) {
|
||||
List<SeckillProduct> products = productMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>()
|
||||
.eq(SeckillProduct::getActivityId, activity.getId())
|
||||
.orderByAsc(SeckillProduct::getSort)
|
||||
);
|
||||
if (CollUtil.isNotEmpty(products)) {
|
||||
// 批量取 SPU / SKU
|
||||
Set<Long> spuIds = products.stream().map(SeckillProduct::getProductId).collect(Collectors.toSet());
|
||||
Set<Long> skuIds = products.stream().map(SeckillProduct::getSkuId).collect(Collectors.toSet());
|
||||
Map<Long, Product> productMap = productEntityMapper.selectBatchIds(spuIds).stream()
|
||||
.collect(Collectors.toMap(Product::getId, p -> p));
|
||||
Map<Long, ProductSku> skuMap = productSkuMapper.selectBatchIds(skuIds).stream()
|
||||
.collect(Collectors.toMap(ProductSku::getId, s -> s));
|
||||
|
||||
vo.setProducts(products.stream().map(sp -> {
|
||||
SeckillProductVO pvo = new SeckillProductVO();
|
||||
BeanUtil.copyProperties(sp, pvo);
|
||||
Product p = productMap.get(sp.getProductId());
|
||||
ProductSku s = skuMap.get(sp.getSkuId());
|
||||
if (p != null) {
|
||||
pvo.setProductName(p.getName());
|
||||
pvo.setProductImage(p.getMainImage());
|
||||
}
|
||||
if (s != null) {
|
||||
pvo.setSkuName(s.getSkuName());
|
||||
}
|
||||
// 实时剩余库存:优先从 Redis 取,Redis 没有再查 MySQL
|
||||
Integer redisRemain = getRedisStock(sp.getActivityId(), sp.getSkuId());
|
||||
Integer remain = redisRemain != null ? redisRemain : sp.getRemainStock();
|
||||
pvo.setRemainStock(remain);
|
||||
// 已售百分比
|
||||
int total = sp.getSeckillStock();
|
||||
int sold = total - Math.max(remain, 0);
|
||||
pvo.setSoldPercent(total == 0 ? 0 : (int) Math.round(sold * 100.0 / total));
|
||||
// 折扣率
|
||||
if (sp.getOriginPrice() != null && sp.getOriginPrice().compareTo(BigDecimal.ZERO) > 0) {
|
||||
pvo.setDiscount(sp.getSeckillPrice()
|
||||
.multiply(BigDecimal.TEN)
|
||||
.divide(sp.getOriginPrice(), 1, RoundingMode.HALF_UP));
|
||||
}
|
||||
return pvo;
|
||||
}).toList());
|
||||
} else {
|
||||
vo.setProducts(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String seckillBuy(SeckillBuyReq req) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
|
||||
// 1. 校验活动
|
||||
SeckillActivity activity = activityMapper.selectById(req.getActivityId());
|
||||
if (activity == null) {
|
||||
throw new BusinessException(ResultCode.SECKILL_NOT_STARTED, "活动不存在");
|
||||
}
|
||||
Integer nowStatus = SeckillActivityStatusEnum.calcStatus(activity.getStartTime(), activity.getEndTime());
|
||||
if (nowStatus != SeckillActivityStatusEnum.IN_PROGRESS.getCode()) {
|
||||
throw new BusinessException(nowStatus == SeckillActivityStatusEnum.NOT_STARTED.getCode()
|
||||
? ResultCode.SECKILL_NOT_STARTED : ResultCode.SECKILL_ENDED);
|
||||
}
|
||||
|
||||
// 2. 校验商品
|
||||
SeckillProduct sp = productMapper.selectOne(
|
||||
new LambdaQueryWrapper<SeckillProduct>()
|
||||
.eq(SeckillProduct::getActivityId, req.getActivityId())
|
||||
.eq(SeckillProduct::getSkuId, req.getSkuId())
|
||||
);
|
||||
if (sp == null) {
|
||||
throw new BusinessException(1000, "活动商品不存在");
|
||||
}
|
||||
|
||||
int qty = req.getQuantity() == null ? 1 : req.getQuantity();
|
||||
if (qty <= 0 || qty > sp.getPerLimit()) {
|
||||
throw new BusinessException(ResultCode.SECKILL_LIMIT_EXCEEDED,
|
||||
"每人限购 " + sp.getPerLimit() + " 件");
|
||||
}
|
||||
|
||||
// 3. 预热检查(如果 Redis 没库存,先 fallback 到 MySQL 库存)
|
||||
String stockKey = String.format(RedisKey.SECKILL_STOCK, req.getActivityId(), req.getSkuId());
|
||||
String userKey = String.format(RedisKey.SECKILL_USER_RECORD, userId, req.getActivityId(), req.getSkuId());
|
||||
|
||||
if (Boolean.FALSE.equals(redisTemplate.hasKey(stockKey))) {
|
||||
// Redis 未预热 → 用 MySQL 库存兜底
|
||||
log.warn("活动 {} SKU {} 未预热到 Redis,使用 MySQL 库存兜底", req.getActivityId(), req.getSkuId());
|
||||
if (sp.getRemainStock() == null || sp.getRemainStock() < qty) {
|
||||
throw new BusinessException(ResultCode.SECKILL_STOCK_EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 执行 Lua 脚本(核心防超卖)
|
||||
List<Long> result;
|
||||
try {
|
||||
result = redisTemplate.execute(
|
||||
seckillScript,
|
||||
Arrays.asList(stockKey, userKey),
|
||||
String.valueOf(sp.getPerLimit()),
|
||||
String.valueOf(qty),
|
||||
String.valueOf(recordTtl)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("Lua 脚本执行异常", e);
|
||||
throw new BusinessException(1000, "系统繁忙,请稍后再试");
|
||||
}
|
||||
|
||||
if (result == null || result.isEmpty()) {
|
||||
throw new BusinessException(1000, "抢购失败");
|
||||
}
|
||||
long code = result.get(0);
|
||||
if (code == -2L) {
|
||||
throw new BusinessException(ResultCode.SECKILL_LIMIT_EXCEEDED);
|
||||
}
|
||||
if (code == -3L) {
|
||||
throw new BusinessException(ResultCode.SECKILL_STOCK_EMPTY);
|
||||
}
|
||||
if (code != 0L) {
|
||||
throw new BusinessException(1000, "抢购失败 code=" + code);
|
||||
}
|
||||
|
||||
// 5. 抢购成功 → 落库(业务简化版同步落库,生产应 MQ 异步)
|
||||
try {
|
||||
return afterSeckillSuccess(userId, sp, qty);
|
||||
} catch (Exception e) {
|
||||
// MySQL 落库失败 → 回滚 Redis(释放库存 + 释放已购标记)
|
||||
log.error("抢购落库失败,回滚 Redis", e);
|
||||
rollbackRedis(stockKey, userKey, qty);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抢购成功后的落库
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
protected String afterSeckillSuccess(Long userId, SeckillProduct sp, int qty) {
|
||||
// 1. 写 seckill_order(唯一索引兜底一人一单)
|
||||
SeckillOrder order = new SeckillOrder();
|
||||
order.setUserId(userId);
|
||||
order.setActivityId(sp.getActivityId());
|
||||
order.setProductId(sp.getProductId());
|
||||
order.setSkuId(sp.getSkuId());
|
||||
order.setQuantity(qty);
|
||||
order.setSeckillPrice(sp.getSeckillPrice());
|
||||
order.setStatus(0); // 待支付
|
||||
try {
|
||||
orderMapper.insert(order);
|
||||
} catch (org.springframework.dao.DuplicateKeyException dup) {
|
||||
// 唯一索引兜底命中(说明 Redis 已被穿透)
|
||||
log.warn("用户 {} 在活动 {} SKU {} 已存在抢购记录", userId, sp.getActivityId(), sp.getSkuId());
|
||||
throw new BusinessException(ResultCode.SECKILL_LIMIT_EXCEEDED, "您已购买过该商品");
|
||||
}
|
||||
|
||||
// 2. 扣减 MySQL 兜底库存
|
||||
productMapper.decrRemainStock(sp.getId(), qty);
|
||||
|
||||
// 3. 累加 MySQL 销售数
|
||||
// (简化:直接更新 sales 字段)
|
||||
sp.setSales((sp.getSales() == null ? 0 : sp.getSales()) + qty);
|
||||
productMapper.updateById(sp);
|
||||
|
||||
// 4. 生成"排队号"(这里直接用 seckill_order.id 作为订单号)
|
||||
String orderNo = "SK" + order.getId();
|
||||
// 写抢购结果到 Redis(用于轮询接口)
|
||||
redisTemplate.opsForValue().set(
|
||||
String.format(RedisKey.SECKILL_RESULT, orderNo),
|
||||
SeckillResultEnum.SUCCESS.getCode().toString(),
|
||||
Duration.ofMinutes(30)
|
||||
);
|
||||
|
||||
log.info("用户 {} 抢购成功 seckillOrderId={} activityId={} skuId={} qty={}",
|
||||
userId, order.getId(), sp.getActivityId(), sp.getSkuId(), qty);
|
||||
return orderNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 Redis
|
||||
*/
|
||||
private void rollbackRedis(String stockKey, String userKey, int qty) {
|
||||
try {
|
||||
redisTemplate.opsForValue().increment(stockKey, qty);
|
||||
redisTemplate.opsForValue().decrement(userKey, qty);
|
||||
} catch (Exception e) {
|
||||
log.error("Redis 回滚失败,请人工介入:stockKey={} userKey={}", stockKey, userKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SeckillResultVO queryResult(String orderNo) {
|
||||
String key = String.format(RedisKey.SECKILL_RESULT, orderNo);
|
||||
String status = redisTemplate.opsForValue().get(key);
|
||||
SeckillResultVO vo = new SeckillResultVO();
|
||||
vo.setOrderNo(orderNo);
|
||||
if (status == null) {
|
||||
vo.setStatus(SeckillResultEnum.PENDING.getCode());
|
||||
vo.setStatusText("排队中,请稍后查询");
|
||||
return vo;
|
||||
}
|
||||
int code = Integer.parseInt(status);
|
||||
vo.setStatus(code);
|
||||
vo.setStatusText(SeckillResultEnum.values()[code].getDesc());
|
||||
|
||||
if (code == SeckillResultEnum.SUCCESS.getCode()) {
|
||||
// 查主订单 ID
|
||||
Long seckillId = parseSeckillIdFromOrderNo(orderNo);
|
||||
if (seckillId != null) {
|
||||
SeckillOrder so = orderMapper.selectById(seckillId);
|
||||
if (so != null) {
|
||||
vo.setMainOrderId(so.getOrderId());
|
||||
}
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Long parseSeckillIdFromOrderNo(String orderNo) {
|
||||
if (orderNo == null || !orderNo.startsWith("SK")) return null;
|
||||
try {
|
||||
return Long.parseLong(orderNo.substring(2));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 Redis 当前库存
|
||||
*/
|
||||
private Integer getRedisStock(Long activityId, Long skuId) {
|
||||
String key = String.format(RedisKey.SECKILL_STOCK, activityId, skuId);
|
||||
String val = redisTemplate.opsForValue().get(key);
|
||||
return val == null ? null : Integer.parseInt(val);
|
||||
}
|
||||
|
||||
// ==================== 管理端 ====================
|
||||
|
||||
@Override
|
||||
public Page<SeckillActivity> pageActivities(Long current, Long size, Integer status) {
|
||||
Page<SeckillActivity> page = new Page<>(current, size);
|
||||
LambdaQueryWrapper<SeckillActivity> wrapper = new LambdaQueryWrapper<SeckillActivity>()
|
||||
.eq(status != null, SeckillActivity::getStatus, status)
|
||||
.orderByDesc(SeckillActivity::getId);
|
||||
return activityMapper.selectPage(page, wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createActivity(SeckillActivitySaveReq req) {
|
||||
if (req.getEndTime().isBefore(req.getStartTime())) {
|
||||
throw new BusinessException(1000, "结束时间不能早于开始时间");
|
||||
}
|
||||
SeckillActivity activity = new SeckillActivity();
|
||||
BeanUtil.copyProperties(req, activity, "id");
|
||||
activity.setStatus(0); // 未开始
|
||||
activityMapper.insert(activity);
|
||||
log.info("创建抢购活动 id={} name={}", activity.getId(), activity.getName());
|
||||
return activity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateActivity(SeckillActivitySaveReq req) {
|
||||
if (req.getId() == null) {
|
||||
throw new BusinessException(1000, "ID 不能为空");
|
||||
}
|
||||
SeckillActivity existing = activityMapper.selectById(req.getId());
|
||||
if (existing == null) {
|
||||
throw new BusinessException(1000, "活动不存在");
|
||||
}
|
||||
if (existing.getStatus() != null
|
||||
&& existing.getStatus() == SeckillActivityStatusEnum.IN_PROGRESS.getCode()) {
|
||||
throw new BusinessException(1000, "进行中的活动不能修改");
|
||||
}
|
||||
SeckillActivity activity = new SeckillActivity();
|
||||
BeanUtil.copyProperties(req, activity);
|
||||
activityMapper.updateById(activity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteActivity(Long id) {
|
||||
SeckillActivity existing = activityMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(1000, "活动不存在");
|
||||
}
|
||||
// 删除活动商品
|
||||
productMapper.delete(
|
||||
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
|
||||
);
|
||||
// 清理 Redis 库存
|
||||
List<SeckillProduct> products = productMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
|
||||
);
|
||||
for (SeckillProduct sp : products) {
|
||||
redisTemplate.delete(String.format(RedisKey.SECKILL_STOCK, id, sp.getSkuId()));
|
||||
}
|
||||
redisTemplate.delete(String.format(RedisKey.SECKILL_WARMUP, id));
|
||||
activityMapper.deleteById(id);
|
||||
log.info("删除活动 id={}", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void forceEndActivity(Long id) {
|
||||
int affected = activityMapper.forceEnd(id);
|
||||
if (affected == 0) {
|
||||
throw new BusinessException(1000, "活动不在进行中");
|
||||
}
|
||||
// 清理 Redis
|
||||
List<SeckillProduct> products = productMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
|
||||
);
|
||||
for (SeckillProduct sp : products) {
|
||||
redisTemplate.delete(String.format(RedisKey.SECKILL_STOCK, id, sp.getSkuId()));
|
||||
}
|
||||
redisTemplate.delete(String.format(RedisKey.SECKILL_WARMUP, id));
|
||||
log.info("强制结束活动 id={}", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warmupActivity(Long id) {
|
||||
SeckillActivity activity = activityMapper.selectById(id);
|
||||
if (activity == null) {
|
||||
throw new BusinessException(1000, "活动不存在");
|
||||
}
|
||||
// 同步所有活动商品的库存到 Redis
|
||||
List<SeckillProduct> products = productMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
|
||||
);
|
||||
for (SeckillProduct sp : products) {
|
||||
String key = String.format(RedisKey.SECKILL_STOCK, id, sp.getSkuId());
|
||||
redisTemplate.opsForValue().set(key, sp.getRemainStock().toString());
|
||||
}
|
||||
// 标记已预热
|
||||
redisTemplate.opsForValue().set(String.format(RedisKey.SECKILL_WARMUP, id), "1");
|
||||
log.info("活动 {} 预热完成,商品数 {}", id, products.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long addProduct(SeckillProductAddReq req) {
|
||||
// 校验活动
|
||||
SeckillActivity activity = activityMapper.selectById(req.getActivityId());
|
||||
if (activity == null) {
|
||||
throw new BusinessException(1000, "活动不存在");
|
||||
}
|
||||
if (activity.getStatus() != null
|
||||
&& activity.getStatus() == SeckillActivityStatusEnum.IN_PROGRESS.getCode()) {
|
||||
throw new BusinessException(1000, "进行中的活动不能添加商品");
|
||||
}
|
||||
// 校验 SKU
|
||||
ProductSku sku = productSkuMapper.selectById(req.getSkuId());
|
||||
if (sku == null) {
|
||||
throw new BusinessException(1000, "SKU 不存在");
|
||||
}
|
||||
// 校验:同一活动同一 SKU 不能重复
|
||||
Long dup = productMapper.selectCount(
|
||||
new LambdaQueryWrapper<SeckillProduct>()
|
||||
.eq(SeckillProduct::getActivityId, req.getActivityId())
|
||||
.eq(SeckillProduct::getSkuId, req.getSkuId())
|
||||
);
|
||||
if (dup > 0) {
|
||||
throw new BusinessException(1000, "该 SKU 已在活动中");
|
||||
}
|
||||
|
||||
SeckillProduct sp = new SeckillProduct();
|
||||
sp.setActivityId(req.getActivityId());
|
||||
sp.setProductId(sku.getProductId());
|
||||
sp.setSkuId(req.getSkuId());
|
||||
sp.setSeckillPrice(req.getSeckillPrice());
|
||||
sp.setOriginPrice(sku.getPrice());
|
||||
sp.setSeckillStock(req.getSeckillStock());
|
||||
sp.setRemainStock(req.getSeckillStock());
|
||||
sp.setPerLimit(req.getPerLimit() == null ? 1 : req.getPerLimit());
|
||||
sp.setSales(0);
|
||||
sp.setSort(req.getSort() == null ? 0 : req.getSort());
|
||||
productMapper.insert(sp);
|
||||
log.info("活动 {} 添加商品 seckillProductId={} skuId={}", req.getActivityId(), sp.getId(), req.getSkuId());
|
||||
return sp.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removeProduct(Long seckillProductId) {
|
||||
SeckillProduct sp = productMapper.selectById(seckillProductId);
|
||||
if (sp == null) {
|
||||
throw new BusinessException(1000, "活动商品不存在");
|
||||
}
|
||||
// 检查活动是否已开始
|
||||
SeckillActivity activity = activityMapper.selectById(sp.getActivityId());
|
||||
if (activity != null
|
||||
&& activity.getStatus() != null
|
||||
&& activity.getStatus() == SeckillActivityStatusEnum.IN_PROGRESS.getCode()) {
|
||||
throw new BusinessException(1000, "进行中的活动不能移除商品");
|
||||
}
|
||||
productMapper.deleteById(seckillProductId);
|
||||
// 清理 Redis
|
||||
redisTemplate.delete(String.format(RedisKey.SECKILL_STOCK, sp.getActivityId(), sp.getSkuId()));
|
||||
log.info("移除活动商品 seckillProductId={}", seckillProductId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.snack.server.module.seckill.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 抢购活动 VO(用户端列表 + 详情)
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "抢购活动")
|
||||
public class SeckillActivityVO implements Serializable {
|
||||
|
||||
@Schema(description = "活动 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "活动名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "活动封面图")
|
||||
private String cover;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "开始时间")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "结束时间")
|
||||
private LocalDateTime endTime;
|
||||
|
||||
/** 动态计算:未开始/进行中/已结束 */
|
||||
@Schema(description = "状态:0-未开始 1-进行中 2-已结束")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "状态文本")
|
||||
private String statusText;
|
||||
|
||||
@Schema(description = "距离开始/结束的秒数(负数表示已结束)")
|
||||
private Long countdownSeconds;
|
||||
|
||||
@Schema(description = "活动说明")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "活动商品列表")
|
||||
private List<SeckillProductVO> products;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.snack.server.module.seckill.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 抢购活动商品 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "抢购活动商品")
|
||||
public class SeckillProductVO implements Serializable {
|
||||
|
||||
@Schema(description = "活动商品 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "所属活动 ID")
|
||||
private Long activityId;
|
||||
|
||||
@Schema(description = "商品 SPU ID")
|
||||
private Long productId;
|
||||
|
||||
@Schema(description = "商品名称")
|
||||
private String productName;
|
||||
|
||||
@Schema(description = "商品主图")
|
||||
private String productImage;
|
||||
|
||||
@Schema(description = "SKU ID")
|
||||
private Long skuId;
|
||||
|
||||
@Schema(description = "SKU 规格")
|
||||
private String skuName;
|
||||
|
||||
@Schema(description = "原价")
|
||||
private BigDecimal originPrice;
|
||||
|
||||
@Schema(description = "抢购价")
|
||||
private BigDecimal seckillPrice;
|
||||
|
||||
@Schema(description = "折扣率,如 6.5 表示 6.5 折")
|
||||
private BigDecimal discount;
|
||||
|
||||
@Schema(description = "总库存")
|
||||
private Integer seckillStock;
|
||||
|
||||
@Schema(description = "剩余库存(Redis 实时值)")
|
||||
private Integer remainStock;
|
||||
|
||||
@Schema(description = "已售百分比 0-100")
|
||||
private Integer soldPercent;
|
||||
|
||||
@Schema(description = "每人限购数量")
|
||||
private Integer perLimit;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.snack.server.module.seckill.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 抢购结果 VO(用户端查询)
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "抢购结果")
|
||||
public class SeckillResultVO implements Serializable {
|
||||
|
||||
@Schema(description = "排队号 / 订单号(用于轮询查询)")
|
||||
private String orderNo;
|
||||
|
||||
@Schema(description = "状态:0-排队中 1-抢购成功 2-抢购失败")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "状态文本")
|
||||
private String statusText;
|
||||
|
||||
@Schema(description = "失败原因(仅失败时有值)")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "主订单 ID(成功时返回)")
|
||||
private Long mainOrderId;
|
||||
}
|
||||
|
|
@ -72,8 +72,35 @@ knife4j:
|
|||
|
||||
# 文件上传本地存储路径(本地存储时使用)
|
||||
snack:
|
||||
storage:
|
||||
# 存储类型:local | s3
|
||||
# local:存储到 ./uploads/ 目录(适合本地开发 / 单机部署)
|
||||
# s3 :存储到 S3 协议的对象存储(RustFS / MinIO / AWS S3 / 阿里云 OSS)
|
||||
type: local
|
||||
|
||||
upload:
|
||||
# 文件上传根路径
|
||||
# 文件上传根路径(仅 local 模式生效)
|
||||
path: ./uploads/
|
||||
# 访问域名前缀(用于拼接图片 URL)
|
||||
# 访问域名前缀(仅 local 模式生效)
|
||||
domain: http://localhost:8080/uploads/
|
||||
|
||||
# S3 / 对象存储配置(仅 storage.type=s3 生效)
|
||||
s3:
|
||||
# S3 兼容服务端点(RustFS / MinIO 示例:http://localhost:9000)
|
||||
endpoint: http://localhost:9000
|
||||
# 区域(AWS S3 必填,RustFS / MinIO 填 us-east-1 即可)
|
||||
region: us-east-1
|
||||
# 是否使用路径风格访问(RustFS / MinIO = true,AWS S3 = false)
|
||||
path-style-access: true
|
||||
# AccessKey
|
||||
access-key: minioadmin
|
||||
# SecretKey
|
||||
secret-key: minioadmin
|
||||
# 存储桶名称(bucket 不存在时会自动尝试创建)
|
||||
bucket: snack-mall
|
||||
# 公网访问域名(用于拼接文件 URL;可配合 CDN 域名)
|
||||
public-domain: http://localhost:9000/snack-mall
|
||||
|
||||
seckill:
|
||||
# 抢购用户限购记录在 Redis 中的过期时间(秒)—— 用于活动结束后清理
|
||||
record-ttl-seconds: 86400
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
-- ============================================================
|
||||
-- 抢购 Lua 脚本(原子完成:限购校验 + 库存扣减)
|
||||
--
|
||||
-- KEYS[1] = 库存 key seckill:stock:{activityId}:{skuId}
|
||||
-- KEYS[2] = 用户限购计数 key seckill:user:{userId}:{activityId}:{skuId}
|
||||
-- ARGV[1] = 限购数量 per_limit
|
||||
-- ARGV[2] = 本次购买 quantity
|
||||
-- ARGV[3] = key 过期时间(秒) —— 用于清理
|
||||
--
|
||||
-- 返回值:
|
||||
-- {0, "OK"} 扣减成功
|
||||
-- {-1, "BOUGHT"} 之前没买过但库存检查发现"已购"标记异常(理论上不会)
|
||||
-- {-2, "LIMIT"} 超过每人限购
|
||||
-- {-3, "EMPTY"} 库存为 0
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 限购校验:累计购买 + 本次 <= 限购
|
||||
local bought = tonumber(redis.call('GET', KEYS[2]) or '0')
|
||||
local perLimit = tonumber(ARGV[1])
|
||||
local qty = tonumber(ARGV[2])
|
||||
if bought + qty > perLimit then
|
||||
return {-2, 'LIMIT_EXCEEDED'}
|
||||
end
|
||||
|
||||
-- 2. 库存校验
|
||||
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if stock < qty then
|
||||
return {-3, 'STOCK_EMPTY'}
|
||||
end
|
||||
|
||||
-- 3. 原子扣库存 + 累计已购
|
||||
local newStock = redis.call('DECRBY', KEYS[1], qty)
|
||||
if newStock < 0 then
|
||||
-- 极端情况:刚好被另一个线程抢光,回滚
|
||||
redis.call('INCRBY', KEYS[1], qty)
|
||||
return {-3, 'STOCK_EMPTY'}
|
||||
end
|
||||
local newBought = redis.call('INCRBY', KEYS[2], qty)
|
||||
local ttl = tonumber(ARGV[3])
|
||||
redis.call('EXPIRE', KEYS[2], ttl)
|
||||
|
||||
return {0, 'OK', newStock, newBought}
|
||||
Loading…
Reference in New Issue