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:
Yuhang Wu 2026-06-05 11:11:19 +08:00
parent a18e4df8a8
commit c9d85b0815
41 changed files with 2589 additions and 27 deletions

View File

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

View File

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

View File

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

View File

@ -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 完整 URLhttp://...
* @return 相对 pathnull 表示无法解析
*/
String parsePathFromUrl(String url);
}

View File

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

View File

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

View File

@ -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 / 阿里云 OSSS3 兼容模式
* 仅当 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 已关闭");
}
}
}

View File

@ -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 / 阿里云 OSSS3 协议
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "snack.s3")
public class S3StorageProperties {
/** 服务端点(含协议,不含 bucket */
private String endpoint;
/** 区域 */
private String region = "us-east-1";
/** 是否路径风格MinIO / RustFS=trueAWS S3=false */
private Boolean pathStyleAccess = true;
/** AccessKey */
private String accessKey;
/** SecretKey */
private String secretKey;
/** 存储桶 */
private String bucket;
/** 公网访问域名前缀(含 bucket */
private String publicDomain;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -58,4 +58,9 @@ public interface CategoryService {
* 调整排序
*/
void updateSort(Long id, Integer sort);
/**
* 更新分类图标
*/
void updateIcon(Long id, String iconUrl);
}

View File

@ -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);
}
// ==================== 私有方法 ====================
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = trueAWS 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

View File

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