fix: connect premium merchants and restrict approval PDFs
| | |
| | | getCarWashMerchantDetail(merchantId.value, cardId.value, (data: any) => { |
| | | if (data == null) { merchantLoading.value = false; merchantError.value = true; return } |
| | | merchantName.value = data.merchantName != null && data.merchantName != '' ? data.merchantName as string : merchantName.value |
| | | const linkType = data.externalLinkType != null ? data.externalLinkType as string : '' |
| | | if (linkType == '') { |
| | | uni.redirectTo({ url: '/pages/merchant/merchant?merchantType=carWash&merchantId=' + encodeURIComponent(merchantId.value) + '&name=' + encodeURIComponent(merchantName.value) }) |
| | | return |
| | | } |
| | | applicableCards.value = data.applicableCards != null && data.applicableCards != '' ? data.applicableCards as string : '请咨询商家' |
| | | servicePointText.value = data.servicePointText != null && data.servicePointText != '' ? data.servicePointText as string : '请咨询商家' |
| | | rightsText.value = data.rightsText != null && data.rightsText != '' ? data.rightsText as string : servicePointText.value |
| | | tipText.value = data.tipText != null ? data.tipText as string : '' |
| | | available.value = data.available === true |
| | | unavailableReason.value = data.unavailableReason != null && data.unavailableReason != '' ? data.unavailableReason as string : '洗车服务当前不可用' |
| | | externalLinkType.value = data.externalLinkType != null ? data.externalLinkType as string : '' |
| | | externalLinkType.value = linkType |
| | | externalMiniProgramShortLink.value = data.externalMiniProgramShortLink != null ? data.externalMiniProgramShortLink as string : '' |
| | | externalMiniProgramPath.value = data.externalMiniProgramPath != null ? data.externalMiniProgramPath as string : '' |
| | | externalWebUrl.value = data.externalWebUrl != null ? data.externalWebUrl as string : '' |
| | |
| | | const goBack = () => safeBack('/pages/benefits/benefits') |
| | | const openService = (item: any) => { |
| | | if (!item.available) { uni.showToast({ title: '该服务已下架', icon: 'none' }); return } |
| | | const cinemaTypes = ['cinema', 'theatre', '电影院', '影院', '影城', '剧院'] |
| | | const cinemaTypes = ['cinema', 'theatre', '观影服务', '电影院', '影院', '影城', '剧院'] |
| | | if (cinemaTypes.indexOf(item.merchantType) < 0) { uni.showToast({ title: '剧院服务详情暂未开放', icon: 'none' }); return } |
| | | uni.navigateTo({ url: '/pages/cinema-merchant/cinema-merchant?merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) }) |
| | | } |
| | |
| | | getTransportTopicMerchants(activeTab.value, currentPage.value + 1, 4, (data: any) => { |
| | | if (requestVersion != listRequestVersion.value) return |
| | | const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : [] |
| | | for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : activeTab.value, name: item.merchantName != null ? item.merchantName as string : '行服务', discount: item.discountText != null ? item.discountText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) } |
| | | for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : activeTab.value, name: item.merchantName != null ? item.merchantName as string : '行服务', discount: item.discountText != null ? item.discountText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false, externalLinkType: item.externalLinkType != null ? item.externalLinkType as string : '' }) } |
| | | visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true |
| | | }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true }) |
| | | } |
| | | const loadMoreServices = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadServices(true) } |
| | | const replaceHeroImage = () => { currentScene.value.image = fallbackImage }; const replaceSceneImage = (index: number) => { if (index >= 0 && index < scenes.value.length) scenes.value[index].image = fallbackImage }; const replaceServiceImage = (item: any) => { item.image = fallbackImage } |
| | | const goBack = () => safeBack('/pages/benefits/benefits') |
| | | const openMerchant = (item: any) => { if (!item.available) { uni.showToast({ title: '该服务已下架', icon: 'none' }); return }; if (item.merchantType == 'carWash' || item.merchantType == '自助洗车') { uni.navigateTo({ url: '/pages/car-wash-merchant/car-wash-merchant?merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) }); return }; const action = item.merchantType == 'parking' && item.discount.indexOf('人才码') >= 0 ? 'talentCode' : ''; uni.navigateTo({ url: '/pages/merchant/merchant?type=transport&merchantType=' + encodeURIComponent(item.merchantType) + '&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) + '&action=' + encodeURIComponent(action) }) } |
| | | const openMerchant = (item: any) => { if (!item.available) { uni.showToast({ title: '该服务已下架', icon: 'none' }); return }; if (item.merchantType == 'carWash' || item.merchantType == '自助洗车') { if (item.externalLinkType == 'miniProgram' || item.externalLinkType == 'web') { uni.navigateTo({ url: '/pages/car-wash-merchant/car-wash-merchant?merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) }); return }; uni.navigateTo({ url: '/pages/merchant/merchant?merchantType=carWash&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) }); return }; const action = item.merchantType == 'parking' && item.discount.indexOf('人才码') >= 0 ? 'talentCode' : ''; uni.navigateTo({ url: '/pages/merchant/merchant?type=transport&merchantType=' + encodeURIComponent(item.merchantType) + '&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) + '&action=' + encodeURIComponent(action) }) } |
| | | </script> |
| | | |
| | | <style> |
| | |
| | | data.append('file', file) |
| | | return http.post<ImageUploadResult, ImageUploadResult>('/api/admin/uploads/images', data) |
| | | } |
| | | export function uploadAdminPdf(file: File) { |
| | | const data = new FormData() |
| | | data.append('file', file) |
| | | return http.post<ImageUploadResult, ImageUploadResult>('/api/admin/uploads/pdfs', data) |
| | | } |
| | | export function uploadAdminVideo(file: File) { |
| | | const data = new FormData() |
| | | data.append('file', file) |
| | |
| | | updateProduct, |
| | | updateProductStatus, |
| | | uploadAdminImage, |
| | | uploadAdminPdf, |
| | | } from '../api/admin' |
| | | import AdminRichTextEditor from '../components/AdminRichTextEditor.vue' |
| | | import type { Coupon, CouponPayload, MerchantPayload, Product, ProductPayload } from '../types/admin' |
| | |
| | | |
| | | function changeCover(target: 'merchant' | 'productCover' | 'productImage' | 'approvalWindow') { |
| | | imageUploadTarget.value = target |
| | | if (imageInputRef.value) imageInputRef.value.accept = target === 'approvalWindow' ? 'application/pdf,.pdf' : 'image/jpeg,image/png,image/webp' |
| | | imageInputRef.value?.click() |
| | | } |
| | | |
| | |
| | | input.value = '' |
| | | if (!file) return |
| | | |
| | | if (imageUploadTarget.value === 'approvalWindow') { |
| | | const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') |
| | | if (!isPdf) { |
| | | ElMessage.warning('窗口信息只能上传 PDF 文件') |
| | | return |
| | | } |
| | | if (file.size > 20 * 1024 * 1024) { |
| | | ElMessage.warning('PDF 文件大小不能超过 20 MB') |
| | | return |
| | | } |
| | | imageUploading.value = true |
| | | try { |
| | | const result = await uploadAdminPdf(file) |
| | | if (approvalUploadIndex.value >= 0) approvalItems.value[approvalUploadIndex.value].windowInfoUrl = result.url |
| | | ElMessage.success('窗口信息上传成功') |
| | | } catch { |
| | | // The shared HTTP interceptor displays the upload error. |
| | | } finally { |
| | | approvalUploadIndex.value = -1 |
| | | imageUploading.value = false |
| | | } |
| | | return |
| | | } |
| | | |
| | | const acceptedTypes = ['image/jpeg', 'image/png', 'image/webp'] |
| | | if (!acceptedTypes.includes(file.type)) { |
| | | ElMessage.warning('请选择 JPG、PNG 或 WEBP 格式的图片') |
| | |
| | | form.coverUrl = result.url |
| | | resetMerchantCoverPreview() |
| | | merchantFormRef.value?.validateField('coverUrl').catch(() => undefined) |
| | | } else if (imageUploadTarget.value === 'approvalWindow') { |
| | | if (approvalUploadIndex.value >= 0) { |
| | | approvalItems.value[approvalUploadIndex.value].windowInfoUrl = result.url |
| | | } |
| | | } else if (imageUploadTarget.value === 'productCover') { |
| | | productForm.coverUrl = result.url |
| | | resetProductCoverPreview() |
| | |
| | | if (serviceId == null || serviceId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "行政审批服务信息不能为空"); |
| | | List<Map<String, Object>> rows = query(""" |
| | | select m.id |
| | | from merchant m join premium_topic_merchant mapping on mapping.merchant_id = m.id |
| | | where m.id = ? and m.deleted = 0 and mapping.deleted = 0 and mapping.category_key = 'approval' |
| | | from merchant m left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 |
| | | where m.id = ? and m.deleted = 0 |
| | | and ((mapping.category_key = 'approval') or (mapping.id is null and m.merchant_type = '行政审批')) |
| | | """, serviceId); |
| | | if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "行政审批服务不存在"); |
| | | return rows.get(0); |
| | |
| | | Map<String, Object> merchant = one(""" |
| | | select id, merchant_name, contact_mobile, address, cover_url, service_label, rights_text, merchant_rights_config, status |
| | | from merchant |
| | | where id = ? and merchant_type in ('carWash', '自主洗车', '自助洗车') and deleted = 0 |
| | | where id = ? and merchant_type in ('carWash', '自主洗车', '自助洗车', '自助洗车服务') and deleted = 0 |
| | | """, merchantId); |
| | | Map<String, Object> service = one(""" |
| | | List<Map<String, Object>> serviceRows = query(""" |
| | | select id, talent_levels, service_point_text, tip_text, external_link_type, |
| | | external_mini_program_app_id, external_mini_program_path, external_web_url, |
| | | unavailable_reason, status |
| | |
| | | where merchant_id = ? and deleted = 0 |
| | | order by id asc limit 1 |
| | | """, merchantId); |
| | | Map<String, Object> service = serviceRows.isEmpty() ? Map.of() : serviceRows.get(0); |
| | | String cardLevel = talentCardId == null ? "" : talentCardLevel(talentCardId); |
| | | List<Map<String, Object>> rights = parseList(merchant.get("merchantRightsConfig")); |
| | | Map<String, Object> right = matchingRight(rights, cardLevel); |
| | | boolean hasCardRightsConfig = !rights.isEmpty() && !cardLevel.isBlank(); |
| | | boolean available = number(merchant.get("status")) == 1 |
| | | && number(service.get("status")) == 1 |
| | | && (serviceRows.isEmpty() || number(service.get("status")) == 1) |
| | | && (!hasCardRightsConfig || right != null) |
| | | && (right == null || booleanValue(right.get("enabled"))); |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | |
| | | }; |
| | | PageResult<Map<String, Object>> result = page(""" |
| | | select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address, |
| | | service_label, status, sort_no, longitude, latitude, business_status, |
| | | service_label, status, sort_no, longitude, latitude, business_status, merchant_rights_config, |
| | | (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price, |
| | | (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price |
| | | from merchant |
| | |
| | | Map<String, Object> item = enrichMerchantSearchRow(row); |
| | | item.put("merchantType", requestedType); |
| | | item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享"))); |
| | | if ("carWash".equals(requestedType)) { |
| | | item.put("externalLinkType", carWashExternalLinkType(item.get("merchantRightsConfig"))); |
| | | } |
| | | item.remove("merchantRightsConfig"); |
| | | return item; |
| | | }).toList(); |
| | | return new PageResult<>(records, result.page(), result.size(), result.total()); |
| | |
| | | where category_key = ? and enabled = 1 and deleted = 0 |
| | | """, Integer.class, categoryId); |
| | | if (categoryCount == null || categoryCount == 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的臻享服务分类"); |
| | | String fallbackMerchantType = premiumFallbackMerchantType(categoryId); |
| | | String serviceWhere = """ |
| | | where m.deleted = 0 and mapping.deleted = 0 and mapping.category_key = ? |
| | | where m.deleted = 0 and ( |
| | | (mapping.deleted = 0 and mapping.category_key = ?) |
| | | or (mapping.id is null and m.merchant_type = ?) |
| | | ) |
| | | """; |
| | | PageResult<Map<String, Object>> result = page(""" |
| | | select m.id, m.merchant_name, m.merchant_type, m.contact_mobile, m.cover_url, m.address, |
| | | m.service_label, m.status, m.sort_no, m.longitude, m.latitude, |
| | | mapping.category_key, mapping.business_type |
| | | coalesce(mapping.category_key, case m.merchant_type |
| | | when '免费体检' then 'medical' when '配偶推荐就业' then 'employment' |
| | | when '金融服务' then 'finance' when '行政审批' then 'approval' |
| | | when '学生托付' then 'children' when '子女托管' then 'children' end) as category_key, |
| | | coalesce(mapping.business_type, case m.merchant_type |
| | | when '免费体检' then 'MEDICAL' when '配偶推荐就业' then 'SPOUSE_EMPLOYMENT' |
| | | when '金融服务' then 'FINANCE' when '行政审批' then 'ADMIN_APPROVAL' |
| | | when '学生托付' then 'CHILD_ENTRUSTMENT' when '子女托管' then 'CHILD_ENTRUSTMENT' end) as business_type |
| | | from merchant m |
| | | join premium_topic_merchant mapping on mapping.merchant_id = m.id |
| | | left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 |
| | | """ + serviceWhere + " order by mapping.sort_no asc, m.sort_no asc, m.id asc", |
| | | "select count(*) from merchant m join premium_topic_merchant mapping on mapping.merchant_id = m.id " + serviceWhere, |
| | | page, size, categoryId); |
| | | "select count(*) from merchant m left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 " + serviceWhere, |
| | | page, size, categoryId, fallbackMerchantType); |
| | | List<Map<String, Object>> records = result.records().stream().map(row -> { |
| | | Map<String, Object> item = enrichMerchantSearchRow(row); |
| | | item.put("businessType", row.get("businessType")); |
| | |
| | | return item; |
| | | }).toList(); |
| | | return new PageResult<>(records, result.page(), result.size(), result.total()); |
| | | } |
| | | |
| | | private String premiumFallbackMerchantType(String categoryId) { |
| | | return switch (categoryId) { |
| | | case "medical" -> "免费体检"; |
| | | case "employment" -> "配偶推荐就业"; |
| | | case "finance" -> "金融服务"; |
| | | case "approval" -> "行政审批"; |
| | | case "children" -> "子女托管"; |
| | | default -> ""; |
| | | }; |
| | | } |
| | | |
| | | private Map<String, Object> enrichMerchantSearchRow(Map<String, Object> row) { |
| | |
| | | } |
| | | } |
| | | |
| | | private String carWashExternalLinkType(Object rightsConfig) { |
| | | for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) { |
| | | if (right.containsKey("externalLinkType") |
| | | || right.containsKey("externalMiniProgramShortLink") |
| | | || right.containsKey("externalMiniProgramPath") |
| | | || right.containsKey("externalWebUrl")) { |
| | | return textOrDefault(right.get("externalLinkType"), ""); |
| | | } |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | private boolean booleanValue(Object value) { |
| | | if (value instanceof Boolean bool) return bool; |
| | | if (value instanceof Number number) return number.intValue() != 0; |
| | |
| | | return ApiResponse.success(imageUploadService.upload(file)); |
| | | } |
| | | |
| | | @PostMapping("/pdfs") |
| | | @ResponseStatus(HttpStatus.CREATED) |
| | | public ApiResponse<ImageUploadResult> uploadPdf(@RequestParam("file") MultipartFile file) { |
| | | return ApiResponse.success(imageUploadService.uploadPdf(file)); |
| | | } |
| | | |
| | | @PostMapping("/videos") |
| | | @ResponseStatus(HttpStatus.CREATED) |
| | | public ApiResponse<ImageUploadResult> uploadVideo(@RequestParam("file") MultipartFile file) throws java.io.IOException { |
| | |
| | | public class ImageUploadService { |
| | | |
| | | private static final long MAX_IMAGE_SIZE = 10L * 1024 * 1024; |
| | | private static final long MAX_PDF_SIZE = 20L * 1024 * 1024; |
| | | |
| | | private final Path uploadDirectory; |
| | | |
| | |
| | | return new ImageUploadResult("/uploads/" + filename); |
| | | } |
| | | |
| | | public ImageUploadResult uploadPdf(MultipartFile file) { |
| | | if (file == null || file.isEmpty()) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择 PDF 文件"); |
| | | } |
| | | if (file.getSize() > MAX_PDF_SIZE) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "PDF 文件大小不能超过 20 MB"); |
| | | } |
| | | try (InputStream input = file.getInputStream()) { |
| | | byte[] header = input.readNBytes(5); |
| | | if (header.length < 5 || header[0] != '%' || header[1] != 'P' || header[2] != 'D' || header[3] != 'F' || header[4] != '-') { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "仅支持 PDF 文件"); |
| | | } |
| | | } catch (IOException exception) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "无法读取 PDF 文件"); |
| | | } |
| | | String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); |
| | | if (!contentType.isBlank() && !"application/pdf".equals(contentType) && !"application/octet-stream".equals(contentType)) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "仅支持 PDF 文件"); |
| | | } |
| | | String filename = UUID.randomUUID() + ".pdf"; |
| | | try { |
| | | Files.createDirectories(uploadDirectory); |
| | | Path destination = uploadDirectory.resolve(filename).normalize(); |
| | | if (!destination.startsWith(uploadDirectory)) throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid PDF filename"); |
| | | Files.copy(file.getInputStream(), destination, StandardCopyOption.REPLACE_EXISTING); |
| | | } catch (IOException exception) { |
| | | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "保存 PDF 文件失败"); |
| | | } |
| | | return new ImageUploadResult("/uploads/" + filename); |
| | | } |
| | | |
| | | private String detectImageExtension(MultipartFile file) { |
| | | byte[] header = new byte[12]; |
| | | try (InputStream input = file.getInputStream()) { |
| | |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("Only JPG, PNG, and WEBP"); |
| | | } |
| | | |
| | | @Test |
| | | void savesPdfWithGeneratedFilename() throws Exception { |
| | | ImageUploadService service = new ImageUploadService(temporaryDirectory); |
| | | MockMultipartFile pdf = new MockMultipartFile("file", "window.pdf", "application/pdf", "%PDF-1.7".getBytes()); |
| | | |
| | | ImageUploadResult result = service.uploadPdf(pdf); |
| | | |
| | | assertThat(result.url()).matches("/uploads/[0-9a-f-]+\\.pdf"); |
| | | try (Stream<Path> files = Files.list(temporaryDirectory)) { |
| | | assertThat(files).hasSize(1); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void rejectsNonPdfUploads() { |
| | | ImageUploadService service = new ImageUploadService(temporaryDirectory); |
| | | MockMultipartFile image = new MockMultipartFile("file", "window.pdf", "application/pdf", "not a pdf".getBytes()); |
| | | |
| | | assertThatThrownBy(() -> service.uploadPdf(image)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("仅支持 PDF"); |
| | | } |
| | | } |