fix: 修复商品图片管理和缓存一致性问题

- 重构 ImageUpload 组件,使用 rawUrl 跟踪原始路径
- 新增 normalizeStorageImageUrl 避免存储绝对 URL
- 商品增删改后同步清除缓存和旧图片文件
- 修复秒杀活动列表商品图片为空的问题
This commit is contained in:
2026-03-14 21:05:42 +08:00
parent 0f67f6cc49
commit 098ea9ad02
10 changed files with 193 additions and 39 deletions

View File

@@ -102,7 +102,10 @@ export const adminApi = {
return request.post<ApiResponse<any>>('/api/admin/products', data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) })) return request.post<ApiResponse<any>>('/api/admin/products', data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) }))
}, },
updateProduct(id: number, data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> { updateProduct(id: number, data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> {
return request.put<ApiResponse<any>>(`/api/admin/products/${id}`, data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) })) return request.put<ApiResponse<any>>(`/api/admin/products/${id}`, data).then((res) => ({
...res,
data: res.data ? normalizeAdminProduct(res.data) : undefined,
}))
}, },
deleteProduct(id: number): Promise<ApiResponse> { return request.delete(`/api/admin/products/${id}`) }, deleteProduct(id: number): Promise<ApiResponse> { return request.delete(`/api/admin/products/${id}`) },
getReviewStats(): Promise<ApiResponse<AdminReviewStats>> { return request.get('/api/admin/reviews/stats') }, getReviewStats(): Promise<ApiResponse<AdminReviewStats>> { return request.get('/api/admin/reviews/stats') },

View File

@@ -4,6 +4,7 @@
:class="{ 'hide-upload': fileList.length >= limit }" :class="{ 'hide-upload': fileList.length >= limit }"
:action="uploadUrl" :action="uploadUrl"
:headers="headers" :headers="headers"
:with-credentials="true"
:file-list="fileList" :file-list="fileList"
:limit="limit" :limit="limit"
:multiple="multiple" :multiple="multiple"
@@ -67,7 +68,7 @@ import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { resolveImageUrl } from '@/utils/image' import { resolveImageUrl } from '@/utils/image'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import type { UploadFile, UploadFiles, UploadRawFile } from 'element-plus' import type { UploadFile, UploadRawFile } from 'element-plus'
interface Props { interface Props {
modelValue?: string | string[] modelValue?: string | string[]
@@ -92,6 +93,10 @@ const emit = defineEmits<{
change: [value: string | string[]] change: [value: string | string[]]
}>() }>()
interface UploadFileWithRawUrl extends UploadFile {
rawUrl?: string
}
const userStore = useUserStore() const userStore = useUserStore()
// 上传相关 // 上传相关
@@ -100,32 +105,42 @@ const headers = computed(() => ({
Authorization: `Bearer ${userStore.token}` Authorization: `Bearer ${userStore.token}`
})) }))
const fileList = ref<UploadFile[]>([]) const fileList = ref<UploadFileWithRawUrl[]>([])
const previewVisible = ref(false) const previewVisible = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
const previewName = ref('') const previewName = ref('')
const previewSize = ref(0) const previewSize = ref(0)
const buildFileItem = (rawUrl: string, index: number): UploadFileWithRawUrl => ({
name: props.multiple ? `image-${index}` : 'image',
url: resolveImageUrl(rawUrl),
status: 'success',
uid: `${Date.now()}-${index}`,
rawUrl,
})
const getRawUrl = (file: UploadFile) => {
const currentFile = file as UploadFileWithRawUrl
return currentFile.rawUrl?.trim() || file.url?.trim() || ''
}
// 初始化文件列表 // 初始化文件列表
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { (val) => {
if (val) { const nextFiles = Array.isArray(val)
if (Array.isArray(val)) { ? val
fileList.value = val.map((url, index) => ({ .map((url) => String(url || '').trim())
name: `image-${index}`, .filter(Boolean)
url: resolveImageUrl(url), .map((url, index) => buildFileItem(url, index))
status: 'success', : val && String(val).trim()
uid: Date.now() + index ? [buildFileItem(String(val).trim(), 0)]
} as UploadFile)) : []
} else {
fileList.value = [{ const currentRawUrls = fileList.value.map((file) => getRawUrl(file))
name: 'image', const nextRawUrls = nextFiles.map((file) => getRawUrl(file))
url: resolveImageUrl(val), if (JSON.stringify(currentRawUrls) !== JSON.stringify(nextRawUrls)) {
status: 'success', fileList.value = nextFiles
uid: Date.now()
} as UploadFile]
}
} }
}, },
{ immediate: true } { immediate: true }
@@ -185,9 +200,33 @@ const beforeUpload = (rawFile: UploadRawFile) => {
} }
// 上传成功 // 上传成功
const handleSuccess = (response: any, file: UploadFile, files: UploadFiles) => { const handleSuccess = (response: any, file: UploadFile) => {
if (response.success) { if (response.success) {
file.url = resolveImageUrl(response.data?.url || response.imageUrl || response.data?.imageUrl) const rawImageUrl = String(response.imageUrl || response.data?.imageUrl || response.data?.url || '').trim()
if (!rawImageUrl) {
handleRemove(file)
ElMessage.error('上传成功但未返回图片地址')
return
}
const uploadedFile = file as UploadFileWithRawUrl
uploadedFile.status = 'success'
uploadedFile.url = resolveImageUrl(rawImageUrl)
uploadedFile.rawUrl = rawImageUrl
if (props.limit === 1 && !props.multiple) {
fileList.value = [uploadedFile]
} else {
const nextFiles = [...fileList.value]
const index = nextFiles.findIndex((item) => item.uid === uploadedFile.uid)
if (index > -1) {
nextFiles[index] = uploadedFile
} else {
nextFiles.push(uploadedFile)
}
fileList.value = nextFiles
}
updateValue() updateValue()
ElMessage.success('上传成功') ElMessage.success('上传成功')
} else { } else {
@@ -197,13 +236,13 @@ const handleSuccess = (response: any, file: UploadFile, files: UploadFiles) => {
} }
// 上传失败 // 上传失败
const handleError = (error: Error, file: UploadFile) => { const handleError = (error: Error) => {
ElMessage.error('上传失败,请重试') ElMessage.error('上传失败,请重试')
console.error('Upload error:', error) console.error('Upload error:', error)
} }
// 超出数量限制 // 超出数量限制
const handleExceed = (files: File[]) => { const handleExceed = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 张图片`) ElMessage.warning(`最多只能上传 ${props.limit} 张图片`)
} }
@@ -225,18 +264,16 @@ const handleDownload = (file: UploadFile) => {
// 删除图片 // 删除图片
const handleRemove = (file: UploadFile) => { const handleRemove = (file: UploadFile) => {
const index = fileList.value.findIndex(f => f.uid === file.uid) fileList.value = fileList.value.filter(f => f.uid !== file.uid)
if (index > -1) { updateValue()
fileList.value.splice(index, 1)
updateValue()
}
} }
// 更新值 // 更新值
const updateValue = () => { const updateValue = () => {
const urls = fileList.value const urls = fileList.value
.filter(f => f.status === 'success' && f.url) .filter(f => f.status === 'success')
.map(f => f.url!) .map(f => getRawUrl(f))
.filter(Boolean)
const value = props.multiple ? urls : urls[0] || '' const value = props.multiple ? urls : urls[0] || ''
emit('update:modelValue', value) emit('update:modelValue', value)

View File

@@ -28,6 +28,30 @@ export const resolveImageUrl = (value?: string | null) => {
return imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}` return imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}`
} }
export const normalizeStorageImageUrl = (value?: string | null) => {
if (!value || !String(value).trim()) {
return ''
}
const imageUrl = String(value).trim()
if (ABSOLUTE_URL_PATTERN.test(imageUrl)) {
try {
const parsed = new URL(imageUrl.startsWith('//') ? `http:${imageUrl}` : imageUrl)
if (
parsed.pathname.startsWith('/uploads/') ||
parsed.pathname.startsWith('/images/') ||
parsed.pathname.startsWith('/static/')
) {
return parsed.pathname
}
} catch {
return imageUrl
}
}
return imageUrl
}
export const applyFallbackImage = (event: Event) => { export const applyFallbackImage = (event: Event) => {
const target = event.target as HTMLImageElement | null const target = event.target as HTMLImageElement | null
if (!target) return if (!target) return

View File

@@ -16,7 +16,7 @@ import type {
AdminRecentOrderRow, AdminRecentOrderRow,
AdminUserRow, AdminUserRow,
} from '@/types/admin' } from '@/types/admin'
import { DEFAULT_PRODUCT_IMAGE, resolveImageUrl } from '@/utils/image' import { DEFAULT_PRODUCT_IMAGE, normalizeStorageImageUrl, resolveImageUrl } from '@/utils/image'
const toNumber = (value: unknown, fallback = 0) => { const toNumber = (value: unknown, fallback = 0) => {
const result = Number(value) const result = Number(value)
@@ -280,7 +280,7 @@ export const normalizeAdminProduct = (product: Record<string, any>): AdminProduc
price: toNumber(product.price), price: toNumber(product.price),
stock: toNumber(product.stock), stock: toNumber(product.stock),
status: toNumber(product.status, 1), status: toNumber(product.status, 1),
imageUrl: resolveImageUrl(toString(product.imageUrl, '')), imageUrl: normalizeStorageImageUrl(toString(product.imageUrl, '')),
createdAt: toIsoLikeString(product.createdAt), createdAt: toIsoLikeString(product.createdAt),
updatedAt: product.updatedAt ? toIsoLikeString(product.updatedAt) : undefined, updatedAt: product.updatedAt ? toIsoLikeString(product.updatedAt) : undefined,
totalSales: toNumber(product.totalSales), totalSales: toNumber(product.totalSales),

View File

@@ -93,7 +93,7 @@ public class ApiController {
item.put("id", flashSale.getId()); item.put("id", flashSale.getId());
item.put("productId", flashSale.getProductId()); item.put("productId", flashSale.getProductId());
item.put("productName", flashSale.getProductName()); item.put("productName", flashSale.getProductName());
item.put("productImage", ""); item.put("productImage", flashSale.getProductImageUrl());
item.put("originalPrice", flashSale.getOriginalPrice()); item.put("originalPrice", flashSale.getOriginalPrice());
item.put("flashPrice", flashSale.getFlashPrice()); item.put("flashPrice", flashSale.getFlashPrice());
item.put("flashStock", flashSale.getFlashStock()); item.put("flashStock", flashSale.getFlashStock());

View File

@@ -112,6 +112,11 @@ public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
".flashStock > 0") ".flashStock > 0")
List<FlashSale> findActiveFlashSalesWithStock(@Param("now") LocalDateTime now); List<FlashSale> findActiveFlashSalesWithStock(@Param("now") LocalDateTime now);
/**
* 根据商品ID查找所有秒杀活动
*/
List<FlashSale> findByProductId(Long productId);
/** /**
* 统计指定时间范围内正在进行的秒杀活动数量 * 统计指定时间范围内正在进行的秒杀活动数量
*/ */

View File

@@ -69,6 +69,12 @@ public class AdminService {
@Autowired @Autowired
private RequestMetricsService requestMetricsService; private RequestMetricsService requestMetricsService;
@Autowired
private FileUploadService fileUploadService;
@Autowired
private ProductService productService;
/** /**
* 获取仪表盘统计数据 * 获取仪表盘统计数据
*/ */
@@ -638,6 +644,8 @@ public class AdminService {
Optional<Product> productOpt = productRepository.findById(id); Optional<Product> productOpt = productRepository.findById(id);
if (productOpt.isPresent()) { if (productOpt.isPresent()) {
Product product = productOpt.get(); Product product = productOpt.get();
String oldImageUrl = product.getImageUrl();
boolean stockUpdated = false;
if (productData.containsKey("name")) { if (productData.containsKey("name")) {
product.setName((String) productData.get("name")); product.setName((String) productData.get("name"));
@@ -647,6 +655,7 @@ public class AdminService {
} }
if (productData.containsKey("stock")) { if (productData.containsKey("stock")) {
product.setStock(Integer.parseInt(productData.get("stock").toString())); product.setStock(Integer.parseInt(productData.get("stock").toString()));
stockUpdated = true;
} }
if (productData.containsKey("category")) { if (productData.containsKey("category")) {
product.setCategory((String) productData.get("category")); product.setCategory((String) productData.get("category"));
@@ -663,6 +672,16 @@ public class AdminService {
product.setUpdatedAt(LocalDateTime.now()); product.setUpdatedAt(LocalDateTime.now());
productRepository.save(product); productRepository.save(product);
if (stockUpdated) {
productService.syncProductStockCache(id, product.getStock());
}
productService.invalidateProductCaches(id);
if (!Objects.equals(oldImageUrl, product.getImageUrl())) {
fileUploadService.deleteProductImage(oldImageUrl);
}
// 清除引用该商品的秒杀活动缓存,确保图片/名称/价格更新及时生效
invalidateFlashSaleCacheByProductId(id);
} else { } else {
throw new RuntimeException("商品不存在"); throw new RuntimeException("商品不存在");
} }
@@ -672,12 +691,37 @@ public class AdminService {
} }
} }
/**
* 清除引用指定商品的所有秒杀活动缓存
*/
private void invalidateFlashSaleCacheByProductId(Long productId) {
try {
List<com.org.flashsalesystem.entity.FlashSale> flashSales =
flashSaleRepository.findByProductId(productId);
for (com.org.flashsalesystem.entity.FlashSale fs : flashSales) {
String cacheKey = "flashsale:" + fs.getId();
redisService.delete(cacheKey);
log.debug("清除秒杀活动缓存: {}", cacheKey);
}
} catch (Exception e) {
log.warn("清除秒杀活动缓存失败: {}", e.getMessage());
}
}
/** /**
* 删除商品 * 删除商品
*/ */
public void deleteProduct(Long id) { public void deleteProduct(Long id) {
try { try {
if (productRepository.existsById(id)) { Optional<Product> productOpt = productRepository.findById(id);
if (productOpt.isPresent()) {
Product product = productOpt.get();
// 清理磁盘上的图片文件
fileUploadService.deleteProductImage(product.getImageUrl());
productService.invalidateProductCaches(id);
productService.removeProductStockCache(id);
// 清除关联的秒杀活动缓存
invalidateFlashSaleCacheByProductId(id);
productRepository.deleteById(id); productRepository.deleteById(id);
} else { } else {
throw new RuntimeException("商品不存在"); throw new RuntimeException("商品不存在");
@@ -705,6 +749,8 @@ public class AdminService {
product.setUpdatedAt(LocalDateTime.now()); product.setUpdatedAt(LocalDateTime.now());
Product savedProduct = productRepository.save(product); Product savedProduct = productRepository.save(product);
productService.syncProductStockCache(savedProduct.getId(), savedProduct.getStock());
productService.invalidateProductCaches(savedProduct.getId());
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("id", savedProduct.getId()); result.put("id", savedProduct.getId());

View File

@@ -129,15 +129,23 @@ public class FileUploadService {
return; return;
} }
// 只处理本地上传的图片 String normalizedImageUrl = imageUrl;
if (!imageUrl.startsWith(urlPrefix)) { if (!imageUrl.startsWith(urlPrefix)) {
int prefixIndex = imageUrl.indexOf(urlPrefix);
if (prefixIndex >= 0) {
normalizedImageUrl = imageUrl.substring(prefixIndex);
}
}
// 只处理本地上传的图片
if (!normalizedImageUrl.startsWith(urlPrefix)) {
log.warn("非本地图片,跳过删除: {}", imageUrl); log.warn("非本地图片,跳过删除: {}", imageUrl);
return; return;
} }
try { try {
// 从URL中提取相对路径 // 从URL中提取相对路径
String relativePath = imageUrl.substring(urlPrefix.length()); String relativePath = normalizedImageUrl.substring(urlPrefix.length());
Path filePath = Paths.get(uploadPath + relativePath); Path filePath = Paths.get(uploadPath + relativePath);
if (Files.exists(filePath)) { if (Files.exists(filePath)) {
@@ -200,4 +208,4 @@ public class FileUploadService {
return "application/octet-stream"; return "application/octet-stream";
} }
} }
} }

View File

@@ -253,6 +253,27 @@ public class ProductService {
return productDTO; return productDTO;
} }
public void invalidateProductCaches(Long productId) {
if (productId != null) {
redisService.delete(PRODUCT_CACHE_PREFIX + productId);
}
clearProductListCache();
}
public void syncProductStockCache(Long productId, Integer stock) {
if (productId == null || stock == null) {
return;
}
redisService.set(PRODUCT_STOCK_PREFIX + productId, stock);
}
public void removeProductStockCache(Long productId) {
if (productId == null) {
return;
}
redisService.delete(PRODUCT_STOCK_PREFIX + productId);
}
/** /**
* 更新商品库存 * 更新商品库存
*/ */
@@ -452,8 +473,10 @@ public class ProductService {
* 清除商品列表缓存 * 清除商品列表缓存
*/ */
private void clearProductListCache() { private void clearProductListCache() {
// 使用通配符删除所有商品列表缓存 Set<String> listCacheKeys = redisService.keys(PRODUCT_LIST_CACHE_PREFIX + "*");
// 注意:这里简化处理,实际生产环境可能需要更精确的缓存管理 if (!listCacheKeys.isEmpty()) {
redisService.delete(listCacheKeys);
}
redisService.delete(HOT_PRODUCTS_CACHE); redisService.delete(HOT_PRODUCTS_CACHE);
} }

View File

@@ -319,6 +319,14 @@ public class RedisService {
return redisTemplate.delete(keys); return redisTemplate.delete(keys);
} }
/**
* 根据模式查找键
*/
public Set<String> keys(String pattern) {
Set<String> keys = stringRedisTemplate.keys(pattern);
return keys == null ? Collections.emptySet() : keys;
}
/** /**
* 检查键是否存在 * 检查键是否存在
*/ */