diff --git a/flash-sale-frontend/src/api/modules/admin.ts b/flash-sale-frontend/src/api/modules/admin.ts index cd80f23..1727c87 100644 --- a/flash-sale-frontend/src/api/modules/admin.ts +++ b/flash-sale-frontend/src/api/modules/admin.ts @@ -102,7 +102,10 @@ export const adminApi = { return request.post>('/api/admin/products', data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) })) }, updateProduct(id: number, data: Record): Promise> { - return request.put>(`/api/admin/products/${id}`, data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) })) + return request.put>(`/api/admin/products/${id}`, data).then((res) => ({ + ...res, + data: res.data ? normalizeAdminProduct(res.data) : undefined, + })) }, deleteProduct(id: number): Promise { return request.delete(`/api/admin/products/${id}`) }, getReviewStats(): Promise> { return request.get('/api/admin/reviews/stats') }, diff --git a/flash-sale-frontend/src/components/common/ImageUpload.vue b/flash-sale-frontend/src/components/common/ImageUpload.vue index cadcb56..334da85 100644 --- a/flash-sale-frontend/src/components/common/ImageUpload.vue +++ b/flash-sale-frontend/src/components/common/ImageUpload.vue @@ -4,6 +4,7 @@ :class="{ 'hide-upload': fileList.length >= limit }" :action="uploadUrl" :headers="headers" + :with-credentials="true" :file-list="fileList" :limit="limit" :multiple="multiple" @@ -67,7 +68,7 @@ import { ref, computed, watch } from 'vue' import { ElMessage } from 'element-plus' import { resolveImageUrl } from '@/utils/image' import { useUserStore } from '@/stores/user' -import type { UploadFile, UploadFiles, UploadRawFile } from 'element-plus' +import type { UploadFile, UploadRawFile } from 'element-plus' interface Props { modelValue?: string | string[] @@ -92,6 +93,10 @@ const emit = defineEmits<{ change: [value: string | string[]] }>() +interface UploadFileWithRawUrl extends UploadFile { + rawUrl?: string +} + const userStore = useUserStore() // 上传相关 @@ -100,32 +105,42 @@ const headers = computed(() => ({ Authorization: `Bearer ${userStore.token}` })) -const fileList = ref([]) +const fileList = ref([]) const previewVisible = ref(false) const previewUrl = ref('') const previewName = ref('') 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( () => props.modelValue, (val) => { - if (val) { - if (Array.isArray(val)) { - fileList.value = val.map((url, index) => ({ - name: `image-${index}`, - url: resolveImageUrl(url), - status: 'success', - uid: Date.now() + index - } as UploadFile)) - } else { - fileList.value = [{ - name: 'image', - url: resolveImageUrl(val), - status: 'success', - uid: Date.now() - } as UploadFile] - } + const nextFiles = Array.isArray(val) + ? val + .map((url) => String(url || '').trim()) + .filter(Boolean) + .map((url, index) => buildFileItem(url, index)) + : val && String(val).trim() + ? [buildFileItem(String(val).trim(), 0)] + : [] + + const currentRawUrls = fileList.value.map((file) => getRawUrl(file)) + const nextRawUrls = nextFiles.map((file) => getRawUrl(file)) + if (JSON.stringify(currentRawUrls) !== JSON.stringify(nextRawUrls)) { + fileList.value = nextFiles } }, { 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) { - 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() ElMessage.success('上传成功') } 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('上传失败,请重试') console.error('Upload error:', error) } // 超出数量限制 -const handleExceed = (files: File[]) => { +const handleExceed = () => { ElMessage.warning(`最多只能上传 ${props.limit} 张图片`) } @@ -225,18 +264,16 @@ const handleDownload = (file: UploadFile) => { // 删除图片 const handleRemove = (file: UploadFile) => { - const index = fileList.value.findIndex(f => f.uid === file.uid) - if (index > -1) { - fileList.value.splice(index, 1) - updateValue() - } + fileList.value = fileList.value.filter(f => f.uid !== file.uid) + updateValue() } // 更新值 const updateValue = () => { const urls = fileList.value - .filter(f => f.status === 'success' && f.url) - .map(f => f.url!) + .filter(f => f.status === 'success') + .map(f => getRawUrl(f)) + .filter(Boolean) const value = props.multiple ? urls : urls[0] || '' emit('update:modelValue', value) diff --git a/flash-sale-frontend/src/utils/image.ts b/flash-sale-frontend/src/utils/image.ts index 5c0927d..b5fe2eb 100644 --- a/flash-sale-frontend/src/utils/image.ts +++ b/flash-sale-frontend/src/utils/image.ts @@ -28,6 +28,30 @@ export const resolveImageUrl = (value?: string | null) => { 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) => { const target = event.target as HTMLImageElement | null if (!target) return diff --git a/flash-sale-frontend/src/utils/normalizers.ts b/flash-sale-frontend/src/utils/normalizers.ts index 716fb11..28e46b1 100644 --- a/flash-sale-frontend/src/utils/normalizers.ts +++ b/flash-sale-frontend/src/utils/normalizers.ts @@ -16,7 +16,7 @@ import type { AdminRecentOrderRow, AdminUserRow, } 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 result = Number(value) @@ -280,7 +280,7 @@ export const normalizeAdminProduct = (product: Record): AdminProduc price: toNumber(product.price), stock: toNumber(product.stock), status: toNumber(product.status, 1), - imageUrl: resolveImageUrl(toString(product.imageUrl, '')), + imageUrl: normalizeStorageImageUrl(toString(product.imageUrl, '')), createdAt: toIsoLikeString(product.createdAt), updatedAt: product.updatedAt ? toIsoLikeString(product.updatedAt) : undefined, totalSales: toNumber(product.totalSales), diff --git a/src/main/java/com/org/flashsalesystem/controller/ApiController.java b/src/main/java/com/org/flashsalesystem/controller/ApiController.java index 53bc446..a6802d5 100644 --- a/src/main/java/com/org/flashsalesystem/controller/ApiController.java +++ b/src/main/java/com/org/flashsalesystem/controller/ApiController.java @@ -93,7 +93,7 @@ public class ApiController { item.put("id", flashSale.getId()); item.put("productId", flashSale.getProductId()); item.put("productName", flashSale.getProductName()); - item.put("productImage", ""); + item.put("productImage", flashSale.getProductImageUrl()); item.put("originalPrice", flashSale.getOriginalPrice()); item.put("flashPrice", flashSale.getFlashPrice()); item.put("flashStock", flashSale.getFlashStock()); diff --git a/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java b/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java index 83efe97..cf4873b 100644 --- a/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java +++ b/src/main/java/com/org/flashsalesystem/repository/FlashSaleRepository.java @@ -112,6 +112,11 @@ public interface FlashSaleRepository extends JpaRepository { ".flashStock > 0") List findActiveFlashSalesWithStock(@Param("now") LocalDateTime now); + /** + * 根据商品ID查找所有秒杀活动 + */ + List findByProductId(Long productId); + /** * 统计指定时间范围内正在进行的秒杀活动数量 */ diff --git a/src/main/java/com/org/flashsalesystem/service/AdminService.java b/src/main/java/com/org/flashsalesystem/service/AdminService.java index 96b8cb9..5158831 100644 --- a/src/main/java/com/org/flashsalesystem/service/AdminService.java +++ b/src/main/java/com/org/flashsalesystem/service/AdminService.java @@ -69,6 +69,12 @@ public class AdminService { @Autowired private RequestMetricsService requestMetricsService; + @Autowired + private FileUploadService fileUploadService; + + @Autowired + private ProductService productService; + /** * 获取仪表盘统计数据 */ @@ -638,6 +644,8 @@ public class AdminService { Optional productOpt = productRepository.findById(id); if (productOpt.isPresent()) { Product product = productOpt.get(); + String oldImageUrl = product.getImageUrl(); + boolean stockUpdated = false; if (productData.containsKey("name")) { product.setName((String) productData.get("name")); @@ -647,6 +655,7 @@ public class AdminService { } if (productData.containsKey("stock")) { product.setStock(Integer.parseInt(productData.get("stock").toString())); + stockUpdated = true; } if (productData.containsKey("category")) { product.setCategory((String) productData.get("category")); @@ -663,6 +672,16 @@ public class AdminService { product.setUpdatedAt(LocalDateTime.now()); 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 { throw new RuntimeException("商品不存在"); } @@ -672,12 +691,37 @@ public class AdminService { } } + /** + * 清除引用指定商品的所有秒杀活动缓存 + */ + private void invalidateFlashSaleCacheByProductId(Long productId) { + try { + List 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) { try { - if (productRepository.existsById(id)) { + Optional 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); } else { throw new RuntimeException("商品不存在"); @@ -705,6 +749,8 @@ public class AdminService { product.setUpdatedAt(LocalDateTime.now()); Product savedProduct = productRepository.save(product); + productService.syncProductStockCache(savedProduct.getId(), savedProduct.getStock()); + productService.invalidateProductCaches(savedProduct.getId()); Map result = new HashMap<>(); result.put("id", savedProduct.getId()); diff --git a/src/main/java/com/org/flashsalesystem/service/FileUploadService.java b/src/main/java/com/org/flashsalesystem/service/FileUploadService.java index 9173db8..cea69e3 100644 --- a/src/main/java/com/org/flashsalesystem/service/FileUploadService.java +++ b/src/main/java/com/org/flashsalesystem/service/FileUploadService.java @@ -129,15 +129,23 @@ public class FileUploadService { return; } - // 只处理本地上传的图片 + String normalizedImageUrl = imageUrl; if (!imageUrl.startsWith(urlPrefix)) { + int prefixIndex = imageUrl.indexOf(urlPrefix); + if (prefixIndex >= 0) { + normalizedImageUrl = imageUrl.substring(prefixIndex); + } + } + + // 只处理本地上传的图片 + if (!normalizedImageUrl.startsWith(urlPrefix)) { log.warn("非本地图片,跳过删除: {}", imageUrl); return; } try { // 从URL中提取相对路径 - String relativePath = imageUrl.substring(urlPrefix.length()); + String relativePath = normalizedImageUrl.substring(urlPrefix.length()); Path filePath = Paths.get(uploadPath + relativePath); if (Files.exists(filePath)) { @@ -200,4 +208,4 @@ public class FileUploadService { return "application/octet-stream"; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/org/flashsalesystem/service/ProductService.java b/src/main/java/com/org/flashsalesystem/service/ProductService.java index 06530e9..817c198 100644 --- a/src/main/java/com/org/flashsalesystem/service/ProductService.java +++ b/src/main/java/com/org/flashsalesystem/service/ProductService.java @@ -253,6 +253,27 @@ public class ProductService { 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() { - // 使用通配符删除所有商品列表缓存 - // 注意:这里简化处理,实际生产环境可能需要更精确的缓存管理 + Set listCacheKeys = redisService.keys(PRODUCT_LIST_CACHE_PREFIX + "*"); + if (!listCacheKeys.isEmpty()) { + redisService.delete(listCacheKeys); + } redisService.delete(HOT_PRODUCTS_CACHE); } diff --git a/src/main/java/com/org/flashsalesystem/service/RedisService.java b/src/main/java/com/org/flashsalesystem/service/RedisService.java index 13e7e99..e2db565 100644 --- a/src/main/java/com/org/flashsalesystem/service/RedisService.java +++ b/src/main/java/com/org/flashsalesystem/service/RedisService.java @@ -319,6 +319,14 @@ public class RedisService { return redisTemplate.delete(keys); } + /** + * 根据模式查找键 + */ + public Set keys(String pattern) { + Set keys = stringRedisTemplate.keys(pattern); + return keys == null ? Collections.emptySet() : keys; + } + /** * 检查键是否存在 */