shinnosuke
2026-08-31 cb5a42c97fe9ae23b891bdc3231dc8eb100de218
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;
@@ -47,7 +49,7 @@
            select id, title, summary, cover_url, source, view_count, is_top, publish_time
            from news_article
            where deleted = 0 and status = 1
            order by is_top desc, case when is_top = 1 then top_time end desc, publish_time desc, id desc
            order by is_top desc, case when is_top = 1 then updated_at end desc, publish_time desc, id desc
            limit 4
            """));
      result.put("policies", query("""
@@ -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
@@ -97,7 +99,7 @@
            select id, title, summary, cover_url, source, content, view_count, is_top, publish_time
            from news_article
            where deleted = 0 and status = 1
            order by is_top desc, case when is_top = 1 then top_time end desc, publish_time desc, id desc
            order by is_top desc, case when is_top = 1 then updated_at end desc, publish_time desc, id desc
            """, "select count(*) from news_article where deleted = 0 and status = 1", page, size);
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> item = new LinkedHashMap<>(row);
@@ -203,6 +205,7 @@
         String mode,
         String sortField,
         String sortOrder,
         Long talentId,
         int page,
         int size
   ) {
@@ -225,14 +228,34 @@
         }
      }
      PageResult<Map<String, Object>> result = page("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                   is_recommend, sort_no, longitude, latitude, business_status,
            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, service_label, merchant_rights_config,
                   (exists(select 1 from merchant_coupon c
                           where c.merchant_id = merchant.id and c.deleted = 0 and c.status = 1
                             and (c.valid_start is null or c.valid_start <= current_date)
                             and (c.valid_end is null or c.valid_end >= current_date))
                    or (merchant.merchant_type_code = 'cinema' and exists(select 1 from ticket_package tp
                           where tp.deleted = 0 and tp.status = 1
                             and (tp.valid_start is null or tp.valid_start <= current_date)
                             and (tp.valid_end is null or tp.valid_end >= current_date)
                             and (tp.merchant_id = merchant.id or exists (
                                 select 1 from ticket_package_merchant tpm
                                 where tpm.package_id = tp.id and tpm.merchant_id = merchant.id and tpm.deleted = 0
                             ))))) as coupon_available,
                   (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
            """ + merchantWhere + " order by sort_no asc, id asc",
            "select count(*) from merchant" + merchantWhere, page, size, args.toArray());
      List<Map<String, Object>> records = result.records().stream().map(this::enrichMerchantSearchRow).toList();
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> item = enrichMerchantSearchRow(row);
         Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
         item.put("couponAvailable", booleanValue(row.get("couponAvailable")));
         item.put("discountText", merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId));
         item.put("benefitTags", merchantListBenefitTags(item, row, talentId));
         item.remove("merchantRightsConfig");
         return item;
      }).toList();
      return new PageResult<>(records, result.page(), result.size(), result.total());
   }
@@ -255,9 +278,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 +316,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
@@ -329,28 +352,107 @@
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size) {
      String requestedType = type == null ? "" : type.trim();
      String merchantType = switch (requestedType) {
         case "hotel", "酒店服务" -> "酒店服务";
         case "apartment", "公寓租赁服务" -> "公寓租赁服务";
      return stayMerchants(type, page, size, null);
   }
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size, Long talentId) {
      String requestedType = MerchantTypeCatalog.canonicalCode(type);
      boolean apartment = switch (requestedType) {
         case "hotel" -> false;
         case "apartment" -> true;
         default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的住宿服务类型");
      };
      String responseMerchantType = merchantType.equals("公寓租赁服务") ? "apartment" : "hotel";
      String merchantWhere = " where deleted = 0 and merchant_type = ?";
      String responseMerchantType = apartment ? "apartment" : "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,
                   status, sort_no, longitude, latitude, business_status,
            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
            from merchant
            """ + merchantWhere + " order by sort_no asc, id asc",
            "select count(*) from merchant" + merchantWhere, page, size, merchantType);
            "select count(*) from merchant" + merchantWhere, page, size);
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> normalized = new LinkedHashMap<>(row);
         normalized.put("merchantType", responseMerchantType);
         String cardLevel = activeProductCardLevel(talentId);
         Map<String, Object> right = matchingMerchantRight(merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, activeTalentSourceAreaType(talentId), true);
         normalized.put("benefitTags", stayBenefitTags(right, row));
         return enrichMerchantSearchRow(normalized);
      }).toList();
      return new PageResult<>(records, result.page(), result.size(), result.total());
   }
   private List<String> stayBenefitTags(Map<String, Object> right, Map<String, Object> merchant) {
      Object merchantId = merchant.get("id");
      boolean couponAvailable = merchantId instanceof Number id && hasActiveMerchantCoupon(id.longValue());
      return stayBenefitTags(right, couponAvailable);
   }
   private List<String> stayBenefitTags(Map<String, Object> right, boolean couponAvailable) {
      List<String> tags = new ArrayList<>();
      if (couponAvailable) tags.add("券");
      if (right == null) return tags;
      if (booleanValue(right.get("freeVoucherEnabled"))) {
         String times = textOrDefault(right.get("freeVoucherTimes"), "");
         if (!times.isBlank()) tags.add("免费" + times + "次");
      }
      if (booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
         String type = textOrDefault(right.get("consumeType"), "");
         String discount = textOrDefault(right.get("consumeDiscount"), "");
         if ("折扣".equals(type) && !discount.isBlank()) tags.add(discount + "折");
      }
      return tags;
   }
   private List<String> merchantListBenefitTags(Map<String, Object> item, Map<String, Object> row, Long talentId) {
      String merchantType = MerchantTypeCatalog.canonicalCode(textOrDefault(item.get("merchantType"), ""));
      boolean couponAvailable = booleanValue(item.get("couponAvailable"));
      String discountText = textOrDefault(item.get("discountText"), "").trim();
      String serviceLabel = textOrDefault(row.get("serviceLabel"), "").trim();
      List<String> tags = new ArrayList<>();
      if ("food".equals(merchantType)) {
         if (couponAvailable) tags.add("券");
         if (!discountText.isBlank()) tags.add(discountText);
         return tags;
      }
      if ("cinema".equals(merchantType)) {
         if (couponAvailable) tags.add("券");
         return tags;
      }
      String cardLevel = activeProductCardLevel(talentId);
      String sourceAreaType = activeTalentSourceAreaType(talentId);
      Map<String, Object> right = matchingMerchantRight(
            merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, sourceAreaType, true);
      if ("hotel".equals(merchantType) || "apartment".equals(merchantType)) {
         tags.addAll(stayBenefitTags(right, couponAvailable));
         if (tags.isEmpty()) tags.add(!discountText.isBlank() ? discountText : "人才专享");
         return tags;
      }
      boolean shoppingType = MerchantTypeCatalog.find(merchantType)
            .map(type -> type.shoppingCategoryKey() != null)
            .orElse(false);
      if (shoppingType) {
         if (couponAvailable) tags.add("券");
         if ("culture".equals(merchantType)) {
            if (right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
               tags.add("到店消费");
            }
         } else if (right != null) {
            String configuredDiscount = merchantRightDiscountText(right);
            if (!configuredDiscount.isBlank()) tags.add(configuredDiscount);
         }
         return tags;
      }
      String categoryTag = !serviceLabel.isBlank() ? serviceLabel : discountText;
      if (categoryTag.isBlank()) categoryTag = "人才专享";
      tags.add(categoryTag);
      return tags;
   }
   @Transactional(readOnly = true)
@@ -372,10 +474,22 @@
   @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,
                   (exists(select 1 from merchant_coupon c
                           where c.merchant_id = merchant.id and c.deleted = 0 and c.status = 1
                             and (c.valid_start is null or c.valid_start <= current_date)
                             and (c.valid_end is null or c.valid_end >= current_date))
                    or exists(select 1 from ticket_package p
                              where p.deleted = 0 and p.status = 1
                                and (p.valid_start is null or p.valid_start <= current_date)
                                and (p.valid_end is null or p.valid_end >= current_date)
                                and (p.merchant_id = merchant.id or exists (
                                    select 1 from ticket_package_merchant pm
                                    where pm.package_id = p.id and pm.merchant_id = merchant.id and pm.deleted = 0
                                )))) as coupon_available,
                   (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
@@ -383,7 +497,9 @@
            "select count(*) from merchant" + merchantWhere, page, size);
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> item = enrichMerchantSearchRow(row);
         item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享")));
         boolean couponAvailable = booleanValue(item.get("couponAvailable"));
         item.put("couponAvailable", couponAvailable);
         item.put("discountText", couponAvailable ? "券" : "");
         return item;
      }).toList();
      return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -409,27 +525,37 @@
   }
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> transportMerchants(String type, int page, int size) {
      if (!List.of("fuel", "parking", "carWash").contains(type)) {
   public PageResult<Map<String, Object>> transportMerchants(String type, int page, int size, Long talentId) {
      String requestedType = MerchantTypeCatalog.canonicalCode(type);
      if (!List.of("fuel", "parking", "carWash").contains(requestedType)) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
      }
      String merchantWhere = " where deleted = 0 and merchant_type = ?";
      if (type.equals("parking")) {
         type = "停车服务";
      }if (type.equals("carWash")) {
         type = "自助洗车";
      }
      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,
                   service_label, status, sort_no, longitude, latitude, business_status,
            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
            from merchant
            """ + merchantWhere + " order by sort_no asc, id asc",
            "select count(*) from merchant" + merchantWhere, page, size, type);
            "select count(*) from merchant" + merchantWhere, page, size);
      String cardLevel = "carWash".equals(requestedType) ? activeProductCardLevel(talentId) : null;
      String sourceAreaType = "carWash".equals(requestedType) ? activeTalentSourceAreaType(talentId) : "";
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> item = enrichMerchantSearchRow(row);
         item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享")));
         item.put("merchantType", requestedType);
         if ("carWash".equals(requestedType)) {
            List<Map<String, Object>> rights = merchantRightsConfigs(item.get("merchantRightsConfig"));
            String externalLinkType = carWashExternalLinkType(rights);
            Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true);
            String discountText = carWashListDiscountText(externalLinkType, right);
            item.put("externalLinkType", externalLinkType);
            item.put("discountText", discountText);
            item.put("benefitTagVisible", !discountText.isBlank());
         } else {
            item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享")));
         }
         item.remove("merchantRightsConfig");
         return item;
      }).toList();
      return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -461,30 +587,76 @@
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> shoppingMerchants(String categoryId, int page, int size) {
      return shoppingMerchants(categoryId, page, size, null);
   }
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> shoppingMerchants(String categoryId, int page, int size, Long talentId) {
      if (categoryId == null || categoryId.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择购物服务分类");
      int categoryCount = jdbcTemplate.queryForObject("""
            select count(*) from shopping_topic_category
            where category_key = ? and enabled = 1 and deleted = 0
            """, Integer.class, categoryId);
      if (categoryCount == 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的购物服务分类");
      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 = 'shopping'
              and mapping.deleted = 0 and mapping.category_key = ?
            """;
            where m.deleted = 0
              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,
                   m.service_label, m.status, m.sort_no, m.longitude, m.latitude, m.business_status
            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, m.merchant_rights_config
            from merchant m
            join shopping_topic_merchant mapping on mapping.merchant_id = m.id
            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 join shopping_topic_merchant mapping on mapping.merchant_id = m.id " + merchantWhere,
            page, size, categoryId);
            "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, args.toArray());
      String cardLevel = activeProductCardLevel(talentId);
      String sourceAreaType = activeTalentSourceAreaType(talentId);
      List<Map<String, Object>> records = result.records().stream().map(row -> {
         Map<String, Object> item = enrichMerchantSearchRow(row);
         item.put("discountText", textOrDefault(item.get("serviceLabel"), "人才专享"));
         List<Map<String, Object>> rights = merchantRightsConfigs(row.get("merchantRightsConfig"));
         Map<String, Object> configuredRight = matchingMerchantRight(rights, cardLevel, sourceAreaType, true);
         Map<String, Object> levelRight = matchingMerchantRight(rights, cardLevel, sourceAreaType, false);
         Long merchantId = row.get("id") instanceof Number number ? number.longValue() : null;
         item.put("couponAvailable", merchantId != null && hasActiveMerchantCoupon(merchantId));
         item.put("storeConsumeAvailable", configuredRight != null
               && booleanValue(configuredRight.get("storeConsume"))
               && booleanValue(configuredRight.get("consumeEnabled")));
         if (configuredRight != null) {
            item.put("discountText", merchantRightDiscountText(configuredRight));
            item.put("rightsAvailable", true);
         } else if (levelRight != null || !rights.isEmpty()) {
            item.put("discountText", levelRight == null ? "" : merchantRightDiscountText(levelRight));
            item.put("rightsAvailable", false);
            item.put("available", false);
            item.put("unavailableReason", levelRight == null ? "当前人才卡暂未配置该权益" : "当前人才卡暂未开通该权益");
         } else {
            item.put("discountText", textOrDefault(item.get("serviceLabel"), ""));
            item.put("rightsAvailable", true);
         }
         item.remove("merchantRightsConfig");
         return item;
      }).toList();
      return new PageResult<>(records, result.page(), result.size(), result.total());
   }
   private String merchantRightDiscountText(Map<String, Object> right) {
      if (!booleanValue(right.get("storeConsume")) || !booleanValue(right.get("consumeEnabled"))) return "";
      String discount = textOrDefault(right.get("discount"), "").trim();
      if (discount.isBlank()) return "";
      try {
         BigDecimal value = new BigDecimal(discount).stripTrailingZeros();
         if (value.compareTo(BigDecimal.ZERO) <= 0 || value.compareTo(BigDecimal.TEN) > 0) return "";
         return value.toPlainString() + "折";
      } catch (NumberFormatException ignored) {
         return "";
      }
   }
   @Transactional(readOnly = true)
@@ -512,29 +684,30 @@
   }
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> premiumServices(String categoryId, int page, int size) {
   public PageResult<Map<String, Object>> premiumServices(String type, int page, int size) {
      String categoryId = jdbcTemplate.query("select category_key from premium_topic_category where category_name = ? and enabled = 1 and deleted = 0 limit 1", rs -> rs.next() ? rs.getString("category_key") : null, type == null ? "" : type.trim());
      if (categoryId == null || categoryId.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择臻享服务分类");
      Integer categoryCount = jdbcTemplate.queryForObject("""
            select count(*) from premium_topic_category
            where category_key = ? and enabled = 1 and deleted = 0
            """, Integer.class, categoryId);
      if (categoryCount == null || categoryCount == 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的臻享服务分类");
      String serviceWhere = """
            where m.deleted = 0 and mapping.deleted = 0 and mapping.category_key = ?
            """;
      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,
                   mapping.category_key, mapping.business_type
                   mapping.sort_no as topic_sort_no
            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 and mapping.category_key = ?
            """ + 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 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;
@@ -557,63 +730,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) {
@@ -641,7 +781,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
@@ -652,12 +792,55 @@
      merchant.put("couponAvailable", hasActiveMerchantCoupon(id));
      merchant.put("discountText", merchantLowestTalentDiscount(id, talentId));
      String cardLevel = activeProductCardLevel(talentId);
      Map<String, Object> configuredRight = matchingMerchantRight(merchant.get("merchantRightsConfig"), cardLevel);
      merchant.put("cardLevel", cardLevel);
      String sourceAreaType = activeTalentSourceAreaType(talentId);
      merchant.put("sourceAreaType", sourceAreaType);
      List<Map<String, Object>> rightsConfigs = merchantRightsConfigs(merchant.get("merchantRightsConfig"));
      Map<String, Object> configuredRight = matchingMerchantRight(rightsConfigs, cardLevel, sourceAreaType, true);
      Map<String, Object> levelRight = matchingMerchantRight(rightsConfigs, cardLevel, sourceAreaType, false);
      boolean freeVoucherEnabled = false;
      String freeVoucherTimes = "";
      String freeVoucherCycle = "";
      String consumeType = "";
      String consumeDiscount = "";
      String consumeCoupon = "";
      boolean storeConsume = false;
      boolean consumeEnabled = false;
      merchant.put("rightsAvailable", configuredRight != null);
      if (configuredRight != null) {
         merchant.put("rightsTag", textOrDefault(configuredRight.get("tag"), ""));
         merchant.put("rightsText", textOrDefault(configuredRight.get("detail"), textOrDefault(merchant.get("rightsText"), "")));
         merchant.put("applicableCards", textOrDefault(configuredRight.get("tag"), cardLevelText(cardLevel) + "可用"));
         merchant.put("applicableCards", textOrDefault(configuredRight.get("tag"), ""));
         merchant.put("rightsStatus", "AVAILABLE");
         freeVoucherEnabled = booleanValue(configuredRight.get("freeVoucherEnabled"));
         freeVoucherTimes = textOrDefault(configuredRight.get("freeVoucherTimes"), "");
         freeVoucherCycle = textOrDefault(configuredRight.get("freeVoucherCycle"), "");
         consumeType = textOrDefault(configuredRight.get("consumeType"), "");
         consumeDiscount = textOrDefault(configuredRight.get("consumeDiscount"), "");
         consumeCoupon = textOrDefault(configuredRight.get("consumeCoupon"), "");
         storeConsume = booleanValue(configuredRight.get("storeConsume"));
         consumeEnabled = booleanValue(configuredRight.get("consumeEnabled"));
      } else if (levelRight != null) {
         merchant.put("rightsTag", textOrDefault(levelRight.get("tag"), ""));
         merchant.put("rightsText", "当前人才卡暂未开通该权益");
         merchant.put("applicableCards", textOrDefault(levelRight.get("tag"), ""));
         merchant.put("rightsStatus", "DISABLED");
      } else if (!rightsConfigs.isEmpty()) {
         merchant.put("rightsTag", "");
         merchant.put("rightsText", "当前人才卡暂未配置该权益");
         merchant.put("applicableCards", "");
         merchant.put("rightsStatus", "MISSING");
      } else {
         merchant.put("rightsStatus", "UNCONFIGURED");
      }
      merchant.put("freeVoucherEnabled", freeVoucherEnabled);
      merchant.put("freeVoucherTimes", freeVoucherTimes);
      merchant.put("freeVoucherCycle", freeVoucherCycle);
      merchant.put("consumeType", consumeType);
      merchant.put("consumeDiscount", consumeDiscount);
      merchant.put("consumeCoupon", consumeCoupon);
      merchant.put("storeConsume", storeConsume);
      merchant.put("consumeEnabled", consumeEnabled);
      merchant.put("usageInstructions", merchantUsageInstructions(
            id, merchant.get("merchantRightsConfig"), cardLevel, merchant.get("usageInstructions")));
      List<String> heroImages = new ArrayList<>();
@@ -678,12 +861,30 @@
      return merchant;
   }
   private Map<String, Object> matchingMerchantRight(Object rightsConfig, String cardLevel) {
   private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, String cardLevel, String sourceAreaType, boolean requireEnabled) {
      if (cardLevel == null) return null;
      for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) {
         if (booleanValue(right.get("enabled")) && sameCardLevel(right.get("level"), cardLevel)) return right;
      List<String> candidates = merchantRightCandidates(cardLevel, sourceAreaType);
      for (String candidate : candidates) {
         for (Map<String, Object> right : rights) {
            if (requireEnabled && !booleanValue(right.get("enabled"))) continue;
            if (candidate.equals(textOrDefault(right.get("level"), ""))) return right;
            if (sameCardLevel(right.get("level"), candidate)) return right;
         }
      }
      return null;
   }
   private List<String> merchantRightCandidates(String cardLevel, String sourceAreaType) {
      String level = normalizedCardLevel(cardLevel);
      String area = normalizedSourceAreaType(sourceAreaType);
      if ("EXCELLENT".equals(level)) {
         if ("LOCAL".equals(area)) return List.of("卓越人才卡(本地)", "卓越人才卡");
         if ("OUTSIDE".equals(area)) return List.of("卓越人才卡(外地)", "卓越人才卡");
         return List.of("卓越人才卡");
      }
      if ("OUTSTANDING".equals(level)) return List.of("杰出人才卡");
      if ("ELITE".equals(level)) return List.of("菁英人才卡");
      return cardLevel == null || cardLevel.isBlank() ? List.of() : List.of(cardLevel);
   }
   private String cardLevelText(String cardLevel) {
@@ -728,6 +929,23 @@
      }
   }
   private String carWashExternalLinkType(List<Map<String, Object>> rights) {
      for (Map<String, Object> right : rights) {
         if (right.containsKey("externalLinkType")
               || right.containsKey("externalMiniProgramShortLink")
               || right.containsKey("externalMiniProgramPath")
               || right.containsKey("externalWebUrl")) {
            return textOrDefault(right.get("externalLinkType"), "");
         }
      }
      return "";
   }
   private String carWashListDiscountText(String externalLinkType, Map<String, Object> right) {
      if ("miniProgram".equals(externalLinkType) || right == null) return "";
      return merchantRightDiscountText(right);
   }
   private boolean booleanValue(Object value) {
      if (value instanceof Boolean bool) return bool;
      if (value instanceof Number number) return number.intValue() != 0;
@@ -737,6 +955,13 @@
   private boolean sameCardLevel(Object configured, String actual) {
      return normalizedCardLevel(configured).equals(normalizedCardLevel(actual));
   }
   private String normalizedSourceAreaType(Object value) {
      String raw = textOrDefault(value, "").toUpperCase(Locale.ROOT);
      if (raw.contains("OUTSIDE") || raw.contains("外地")) return "OUTSIDE";
      if (raw.contains("LOCAL") || raw.contains("本地")) return "LOCAL";
      return "";
   }
   private String normalizedCardLevel(Object value) {
@@ -944,9 +1169,13 @@
      PageResult<Map<String, Object>> result = page(sql.toString(), countSql.toString(), page, size, args.toArray());
      List<Map<String, Object>> records = result.records().stream().map(product -> {
         Map<String, Object> item = new LinkedHashMap<>(product);
         BigDecimal configuredSalePrice = decimal(product.get("salePrice"));
         BigDecimal configuredOriginalPrice = decimal(product.get("originalPrice"));
         BigDecimal originalPrice = decimal(item.get("originalPrice"));
         if (originalPrice.signum() <= 0) originalPrice = decimal(item.get("salePrice"));
         BigDecimal personalizedPrice = personalizedProductPrice(item, talentId);
         item.put("configuredSalePrice", configuredSalePrice);
         item.put("configuredOriginalPrice", configuredOriginalPrice.signum() > 0 ? configuredOriginalPrice : originalPrice);
         item.put("originalPrice", originalPrice);
         item.put("salePrice", personalizedPrice);
         item.put("personalized", talentId != null && personalizedPrice.compareTo(decimal(product.get("salePrice"))) != 0);
@@ -993,6 +1222,18 @@
      return cardLevel == null ? null : String.valueOf(cardLevel);
   }
   private String activeTalentSourceAreaType(Long talentId) {
      if (talentId == null) return "";
      List<Map<String, Object>> rows = query("""
            select source_area_type
            from talent_user
            where deleted = 0 and status = 1 and id = ?
            limit 1
            """, talentId);
      if (rows.isEmpty()) return "";
      return normalizedSourceAreaType(rows.get(0).get("sourceAreaType"));
   }
   private int usedFreeCount(Long talentId, Object productId, Object rightsConfig, Object cardLevel) {
      ProductPriceCalculator.ProductRight right = ProductPriceCalculator.productRight(rightsConfig, cardLevel);
      if (right == null || !right.free() || !(productId instanceof Number number)) return 0;