shinnosuke
2026-08-20 40dd0e9898bf2a88e51d497c46d826ea8f7326f4
fix: connect premium merchants and restrict approval PDFs
11个文件已修改
171 ■■■■■ 已修改文件
rencaika/pages/car-wash-merchant/car-wash-merchant.uvue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/transport-benefits/transport-benefits.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/upload/AdminUploadController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/upload/ImageUploadService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/upload/ImageUploadServiceTests.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/car-wash-merchant/car-wash-merchant.uvue
@@ -118,13 +118,18 @@
        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 : ''
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue
@@ -168,7 +168,7 @@
    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) })
    }
rencaika/pages/transport-benefits/transport-benefits.uvue
@@ -102,14 +102,14 @@
        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>
talent-card-admin/src/api/admin.ts
@@ -66,6 +66,11 @@
  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)
talent-card-admin/src/views/MerchantCreateView.vue
@@ -22,6 +22,7 @@
  updateProduct,
  updateProductStatus,
  uploadAdminImage,
  uploadAdminPdf,
} from '../api/admin'
import AdminRichTextEditor from '../components/AdminRichTextEditor.vue'
import type { Coupon, CouponPayload, MerchantPayload, Product, ProductPayload } from '../types/admin'
@@ -1274,6 +1275,7 @@
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()
}
@@ -1295,6 +1297,30 @@
  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 格式的图片')
@@ -1312,10 +1338,6 @@
      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()
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
@@ -71,8 +71,9 @@
        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);
talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java
@@ -41,9 +41,9 @@
        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
@@ -51,12 +51,13 @@
                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<>();
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -449,7 +449,7 @@
        };
        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
@@ -459,6 +459,10 @@
            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());
@@ -557,18 +561,29 @@
                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"));
@@ -578,6 +593,17 @@
            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) {
@@ -812,6 +838,18 @@
        }
    }
    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;
talent-card-server/src/main/java/com/talentcard/server/upload/AdminUploadController.java
@@ -29,6 +29,12 @@
        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 {
talent-card-server/src/main/java/com/talentcard/server/upload/ImageUploadService.java
@@ -19,6 +19,7 @@
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;
@@ -56,6 +57,37 @@
        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()) {
talent-card-server/src/test/java/com/talentcard/server/upload/ImageUploadServiceTests.java
@@ -40,4 +40,27 @@
                .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");
    }
}