shinnosuke
2026-08-21 bc2efbdb52190fe2e7be9920bbed6902d4ee28aa
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -6,14 +6,16 @@
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Collections;
import java.util.Locale;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.MerchantTypeCatalog;
import com.talentcard.server.business.merchant.MerchantTypeCatalog.MerchantType;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.jsoup.Jsoup;
@@ -65,7 +67,7 @@
            limit 8
            """));
      result.put("recommendedMerchants", query("""
            select id, merchant_name, merchant_type, logo_url, cover_url, address
            select id, merchant_name, merchant_type_code as merchant_type, logo_url, cover_url, address
            from merchant
            where deleted = 0 and status = 1 and is_recommend = 1
            order by sort_no desc, id desc
@@ -225,7 +227,7 @@
         }
      }
      PageResult<Map<String, Object>> result = page("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
            select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   is_recommend, sort_no, longitude, latitude, business_status,
                   (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
@@ -255,9 +257,9 @@
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> foodMerchants(int page, int size, Long talentId) {
      String merchantWhere = " where deleted = 0 and merchant_type in ('food', '餐饮服务')";
      String merchantWhere = " where deleted = 0 and merchant_type_code = 'food'";
      PageResult<Map<String, Object>> result = page("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
            select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   status, sort_no, longitude, latitude, business_status,
                   (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
@@ -293,9 +295,9 @@
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> travelMerchants(int page, int size) {
      String merchantWhere = " where deleted = 0 and merchant_type in ('scenic', '景区服务')";
      String merchantWhere = " where deleted = 0 and merchant_type_code = 'scenic'";
      PageResult<Map<String, Object>> result = page("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
            select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   status, sort_no, longitude, latitude, business_status,
                   (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
@@ -334,18 +336,16 @@
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size, Long talentId) {
      String requestedType = type == null ? "" : type.trim();
      String requestedType = MerchantTypeCatalog.canonicalCode(type);
      boolean apartment = switch (requestedType) {
         case "hotel", "酒店服务" -> false;
         case "apartment", "公寓租赁", "公寓租赁服务" -> true;
         case "hotel" -> false;
         case "apartment" -> true;
         default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的住宿服务类型");
      };
      String responseMerchantType = apartment ? "apartment" : "hotel";
      String merchantWhere = apartment
            ? " where deleted = 0 and merchant_type in ('apartment', '公寓租赁', '公寓租赁服务')"
            : " where deleted = 0 and merchant_type in ('hotel', '酒店服务')";
      String merchantWhere = " where deleted = 0 and merchant_type_code = '" + responseMerchantType + "'";
      PageResult<Map<String, Object>> result = page("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
            select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   status, sort_no, longitude, latitude, business_status, group_buying_enabled, 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
@@ -399,9 +399,9 @@
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> entertainmentMerchants(int page, int size) {
      String merchantWhere = " where deleted = 0 and merchant_type in ('cinema', 'theatre', '观影服务', '电影院', '影院', '影城', '剧院')";
      String merchantWhere = " where deleted = 0 and merchant_type_code = 'cinema'";
      PageResult<Map<String, Object>> result = page("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
            select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   service_label, status, sort_no, longitude, latitude, business_status,
                   (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
@@ -437,18 +437,13 @@
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> transportMerchants(String type, int page, int size) {
      if (!List.of("fuel", "parking", "carWash").contains(type)) {
      String requestedType = MerchantTypeCatalog.canonicalCode(type);
      if (!List.of("fuel", "parking", "carWash").contains(requestedType)) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
      }
      String requestedType = type;
      String merchantWhere = switch (type) {
         case "fuel" -> " where deleted = 0 and merchant_type in ('fuel', '加油服务')";
         case "parking" -> " where deleted = 0 and merchant_type in ('parking', '停车服务')";
         case "carWash" -> " where deleted = 0 and merchant_type in ('carWash', '自助洗车', '自助洗车服务')";
         default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
      };
      String merchantWhere = " where deleted = 0 and merchant_type_code = '" + requestedType + "'";
      PageResult<Map<String, Object>> result = page("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
            select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   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
@@ -500,26 +495,24 @@
            where category_key = ? and enabled = 1 and deleted = 0
            """, Integer.class, categoryId);
      if (categoryCount == 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的购物服务分类");
      String merchantTypeFilter = switch (categoryId) {
         case "housing" -> "('购房优惠')";
         case "car" -> "('购车服务')";
         case "appliance" -> "('家电服务')";
         case "digital" -> "('数码服务')";
         case "book" -> "('文化服务', '图书服务')";
         default -> "('')";
      };
      List<String> merchantTypeCodes = MerchantTypeCatalog.codesForShoppingCategory(categoryId);
      if (merchantTypeCodes.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "该购物分类未配置商家类型");
      String placeholders = String.join(",", java.util.Collections.nCopies(merchantTypeCodes.size(), "?"));
      String merchantWhere = """
            where m.deleted = 0
              and (m.merchant_type in %s or (m.merchant_type = 'shopping' and mapping.deleted = 0 and mapping.category_key = ?))
            """.formatted(merchantTypeFilter);
              and m.merchant_type_code in (%s)
            """.formatted(placeholders);
      List<Object> args = new ArrayList<>();
      args.add(categoryId);
      args.addAll(merchantTypeCodes);
      PageResult<Map<String, Object>> result = page("""
            select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile, m.logo_url, m.cover_url, m.address,
            select m.id, m.merchant_name, m.merchant_type_code as merchant_type, m.contact_name, m.contact_mobile, m.logo_url, m.cover_url, m.address,
                   m.service_label, m.status, m.sort_no, m.longitude, m.latitude, m.business_status
            from merchant m
            left join shopping_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ?
            """ + merchantWhere + " order by mapping.sort_no asc, m.sort_no asc, m.id asc",
            "select count(*) from merchant m left join shopping_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ? " + merchantWhere,
            page, size, categoryId, categoryId);
            page, size, args.toArray());
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> item = enrichMerchantSearchRow(row);
         item.put("discountText", textOrDefault(item.get("serviceLabel"), "人才专享"));
@@ -561,49 +554,27 @@
            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 = ?)
               or (mapping.id is null and m.merchant_type = ?)
            )
            """;
      MerchantType merchantType = MerchantTypeCatalog.forPremiumCategory(categoryId)
            .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "该臻享分类未配置商家类型"));
      String serviceWhere = " where m.deleted = 0 and m.merchant_type_code = ?";
      PageResult<Map<String, Object>> result = page("""
            select m.id, m.merchant_name, m.merchant_type, m.contact_mobile, m.cover_url, m.address,
            select m.id, m.merchant_name, m.merchant_type_code as merchant_type, m.contact_mobile, m.cover_url, m.address,
                   m.service_label, m.status, m.sort_no, m.longitude, m.latitude,
                   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
                   mapping.sort_no as topic_sort_no
            from merchant m
            left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0
            left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ?
            """ + serviceWhere + " order by mapping.sort_no asc, m.sort_no asc, m.id asc",
            "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);
            "select count(*) from merchant m left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ? " + serviceWhere,
            page, size, categoryId, merchantType.code());
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> item = enrichMerchantSearchRow(row);
         item.put("businessType", row.get("businessType"));
         item.put("targetAction", row.get("businessType"));
         item.put("businessType", merchantType.premiumBusinessType());
         item.put("targetAction", merchantType.premiumBusinessType());
         item.put("benefitText", textOrDefault(row.get("serviceLabel"), "人才专享"));
         item.put("discountText", textOrDefault(row.get("serviceLabel"), "人才专享"));
         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) {
@@ -621,63 +592,30 @@
   }
   private String categoryName(String merchantType) {
      return switch (merchantType) {
         case "food", "餐饮服务" -> "暖心食享";
         case "hotel", "apartment" -> "舒心安居";
         case "cinema", "theatre" -> "怡乐生活";
         case "scenic", "景区服务" -> "悦览花城";
         case "fuel", "parking", "carWash" -> "畅行无忧";
         case "shopping" -> "惠享好物";
         case "medical", "finance" -> "臻享礼遇";
         default -> "其他权益";
      };
      return MerchantTypeCatalog.find(merchantType).map(MerchantType::topicName).orElse("其他权益");
   }
   private String appendMerchantTypeFilter(String merchantWhere, List<String> merchantTypes, List<Object> args) {
      if (merchantTypes.size() == 1) {
         args.add(merchantTypes.get(0));
         return merchantWhere + " and merchant_type = ?";
         return merchantWhere + " and merchant_type_code = ?";
      }
      args.addAll(merchantTypes);
      String placeholders = String.join(",", Collections.nCopies(merchantTypes.size(), "?"));
      return merchantWhere + " and merchant_type in (" + placeholders + ")";
      return merchantWhere + " and merchant_type_code in (" + placeholders + ")";
   }
   private List<String> merchantTypesForRequestedValue(String value) {
      List<String> categoryTypes = merchantTypesForCategory(value);
      return categoryTypes.isEmpty() ? List.of(value) : categoryTypes;
      List<String> types = MerchantTypeCatalog.codesForRequestedValue(value);
      return types.isEmpty() ? List.of("other") : types;
   }
   private List<String> merchantTypesForCategory(String value) {
      return switch (value) {
         case "暖心食享", "餐饮服务", "food" -> List.of("food", "餐饮服务");
         case "舒心安居", "hotel" -> List.of("hotel");
         case "怡乐生活", "cinema", "theatre" -> List.of("cinema", "theatre");
         case "悦览花城", "scenic", "景区服务" -> List.of("scenic", "景区服务");
         case "畅行无忧" -> List.of("fuel", "parking", "carWash");
         case "fuel" -> List.of("fuel");
         case "parking" -> List.of("parking");
         case "carWash" -> List.of("carWash");
         case "惠享好物", "shopping" -> List.of("shopping");
         case "臻享礼遇" -> List.of("medical", "finance");
         case "medical" -> List.of("medical");
         case "finance" -> List.of("finance");
         default -> List.of();
      };
      return MerchantTypeCatalog.codesForRequestedValue(value);
   }
   private String detailType(String merchantType) {
      return switch (merchantType) {
         case "cinema" -> "CINEMA";
         case "theatre" -> "THEATRE";
         case "shopping" -> "SHOPPING";
         case "medical" -> "MEDICAL";
         case "finance" -> "FINANCE";
         case "carWash" -> "CAR_WASH";
         case "fuel" -> "FUEL";
         case "parking" -> "PARKING";
         default -> "MERCHANT";
      };
      return MerchantTypeCatalog.find(merchantType).map(MerchantType::detailType).orElse("MERCHANT");
   }
   private String targetAction(String merchantType) {
@@ -705,7 +643,7 @@
   @Transactional(readOnly = true)
   public Map<String, Object> merchantDetail(Long id, Long talentId) {
      Map<String, Object> merchant = one("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
            select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   business_status, longitude, latitude, group_buying_enabled, usage_instructions, rights_text, merchant_rights_config,
                   service_label, status, is_recommend, sort_no
            from merchant