shinnosuke
2026-08-19 ac44d2c693c433ed230652ed3f1f6516042309c5
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -2,14 +2,19 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.talentcard.server.business.BusinessIdGenerator;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPasswordHasher;
@@ -23,6 +28,12 @@
@Service
public class AdminBusinessService {
   private static final Set<String> MERCHANT_APP_LOGIN_TYPES = Set.of(
         "餐饮服务", "景区服务", "免费体检", "配偶推荐就业", "金融服务", "行政审批", "子女托管",
         "酒店服务", "公寓租赁", "加油服务", "停车服务", "自助洗车", "购房优惠", "购车服务",
         "家电服务", "数码服务", "文化服务", "电信服务", "口腔治疗保健"
   );
   private final JdbcTemplate jdbcTemplate;
@@ -53,7 +64,7 @@
         int size
   ) {
      StringBuilder sql = new StringBuilder("""
            select id, title, summary, cover_url, source, author, view_count, is_top, sort_no,
            select id, title, summary, cover_url, source, author, view_count, is_top, top_time, sort_no,
                   publish_time, status, created_at, updated_at
            from news_article
            where deleted = 0
@@ -87,14 +98,14 @@
         countSql.append(" and date(publish_time) <= ?");
         args.add(publishEnd);
      }
      sql.append(" order by is_top desc, sort_no desc, publish_time desc, id desc");
      sql.append(" order by is_top desc, case when is_top = 1 then top_time end desc, publish_time desc, id desc");
      return page(sql.toString(), countSql.toString(), page, size, args.toArray());
   }
   @Transactional(readOnly = true)
   public Map<String, Object> newsDetail(Long id) {
      return one("""
            select id, title, summary, cover_url, source, author, content, view_count, is_top, sort_no,
            select id, title, summary, cover_url, source, author, content, view_count, is_top, top_time, sort_no,
                   publish_time, status, created_at, updated_at
            from news_article
            where deleted = 0 and id = ?
@@ -102,18 +113,19 @@
   }
   @Transactional
   public Map<String, Object> createNews(NewsArticleRequest request) {
   public Map<String, Object> createNews(NewsArticleRequest request, String operatorName) {
      int status = defaultNewsStatus(request.status());
      jdbcTemplate.update("""
            insert into news_article(title, summary, cover_url, source, author, content, is_top, sort_no, publish_time, status)
            values (?, ?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?)
            insert into news_article(title, summary, cover_url, source, author, content, is_top, top_time, sort_no, publish_time, status)
            values (?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?, case when ? = 1 then now() else null end, ?)
            """,
            request.title().trim(),
            blankToNull(request.summary()),
            blankToNull(request.coverUrl()),
            blankToNull(request.source()),
            blankToNull(request.author()),
            null,
            blankToNull(operatorName),
            sanitizeNewsContent(request.content()),
            Boolean.TRUE.equals(request.isTop()) ? 1 : 0,
            Boolean.TRUE.equals(request.isTop()) ? 1 : 0,
            request.sortNo() == null ? 0 : request.sortNo(),
            status,
@@ -127,16 +139,18 @@
      int status = defaultNewsStatus(request.status());
      int updated = jdbcTemplate.update("""
            update news_article
            set title = ?, summary = ?, cover_url = ?, source = ?, author = ?, content = ?, is_top = ?, sort_no = ?,
            set title = ?, summary = ?, cover_url = ?, content = ?,
                top_time = case when ? = 1 and coalesce(is_top, 0) = 0 then now() when ? = 0 then null else top_time end,
                is_top = ?, sort_no = ?,
                status = ?, publish_time = case when ? = 1 then coalesce(publish_time, now()) else publish_time end
            where deleted = 0 and id = ?
            """,
            request.title().trim(),
            blankToNull(request.summary()),
            blankToNull(request.coverUrl()),
            blankToNull(request.source()),
            blankToNull(request.author()),
            sanitizeNewsContent(request.content()),
            Boolean.TRUE.equals(request.isTop()) ? 1 : 0,
            Boolean.TRUE.equals(request.isTop()) ? 1 : 0,
            Boolean.TRUE.equals(request.isTop()) ? 1 : 0,
            request.sortNo() == null ? 0 : request.sortNo(),
            status,
@@ -180,10 +194,10 @@
   }
   @Transactional
   public Map<String, Object> createPolicy(PolicyArticleRequest request) {
   public Map<String, Object> createPolicy(PolicyArticleRequest request, String operatorName) {
      int status = defaultNewsStatus(request.status());
      jdbcTemplate.update("insert into policy_article(policy_no, title, subtitle, cover_url, author, source, content, is_top, sort_no, publish_time, status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?)",
            BusinessIdGenerator.next("POLICY"), request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status);
            BusinessIdGenerator.next("POLICY"), request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(operatorName), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status);
      return policyDetail(jdbcTemplate.queryForObject("select last_insert_id()", Long.class));
   }
@@ -218,21 +232,7 @@
      StringBuilder sql = new StringBuilder("""
            select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile,
                   m.logo_url, m.cover_url, m.address, m.is_recommend, m.sort_no, m.status,
                   case
                     when m.status <> 1 then '休息中'
                     when lower(coalesce(m.business_status, 'open')) in ('closed', '休息中') then '休息中'
                     when exists (
                       select 1
                       from merchant_business_hours h
                       join merchant_store s on s.id = h.store_id and s.status = 1 and s.deleted = 0
                       where h.merchant_id = m.id
                         and (
                           (h.start_time <= h.end_time and current_time() >= h.start_time and current_time() < h.end_time)
                           or (h.start_time > h.end_time and (current_time() >= h.start_time or current_time() < h.end_time))
                         )
                     ) then '营业中'
                     else '休息中'
                   end as business_status, m.created_at,
                   m.created_at,
                   (select count(*) from merchant_product p where p.merchant_id = m.id and p.deleted = 0) as product_count,
                   (select count(*) from benefit_order o where o.merchant_id = m.id and o.deleted = 0) as order_count,
                   0 as reservation_count
@@ -280,20 +280,65 @@
         args.add(createdEnd);
      }
      sql.append(" order by m.is_recommend desc, m.sort_no desc, m.id desc");
      return page(sql.toString(), countSql.toString(), page, size, args.toArray());
      PageResult<Map<String, Object>> result = page(sql.toString(), countSql.toString(), page, size, args.toArray());
      applyBusinessStatus(result.records());
      return result;
   }
   @Transactional(readOnly = true)
   public List<Map<String, Object>> pendingNotices() {
      List<Map<String, Object>> notices = new ArrayList<>();
      addPendingNotice(notices, "product-audit", "团单审核", "您有 %d 条团单待审核", "/products",
            "select count(*) as pending_count, max(coalesce(submit_time, created_at)) as latest_time from merchant_product where deleted = 0 and audit_status = 1");
      addPendingNotice(notices, "family-card", "家属卡申请", "您有 %d 条家属卡申请待审核", "/family-card-applications",
            "select count(*) as pending_count, max(apply_time) as latest_time from family_card_application where deleted = 0 and status = '待审核'");
      addPendingNotice(notices, "upgrade", "升级申请", "您有 %d 条升级申请待审核", "/upgrade-applications",
            "select count(*) as pending_count, max(apply_time) as latest_time from upgrade_application where deleted = 0 and status = '待审核'");
      addPendingNotice(notices, "merchant-onboarding", "商户进件", "您有 %d 条商户进件待审核", "/merchant-settlement?kind=onboardings",
            "select count(*) as pending_count, max(created_at) as latest_time from merchant_onboarding_application where status = 'pending'");
      addPendingNotice(notices, "merchant-bank-card", "银行卡申请", "您有 %d 条银行卡申请待审核", "/merchant-settlement?kind=bank-cards",
            "select count(*) as pending_count, max(created_at) as latest_time from merchant_bank_card where status = 'pending'");
      addPendingNotice(notices, "merchant-withdrawal", "提现审核", "您有 %d 条提现申请待审核", "/merchant-settlement?kind=withdrawals",
            "select count(*) as pending_count, max(created_at) as latest_time from merchant_withdrawal where status = 'pending'");
      addPendingNotice(notices, "medical-reservation", "体检预约", "您有 %d 条体检预约待处理", "/medical-reservations",
            "select count(*) as pending_count, max(submitted_at) as latest_time from service_application where deleted = 0 and application_type = 'MEDICAL_RESERVATION' and status = '待处理'");
      addPendingNotice(notices, "medical-application", "就医申请", "您有 %d 条就医申请待处理", "/medical-applications",
            "select count(*) as pending_count, max(submitted_at) as latest_time from service_application where deleted = 0 and application_type = 'MEDICAL_APPLICATION' and status = '待处理'");
      notices.sort((left, right) -> String.valueOf(right.get("time")).compareTo(String.valueOf(left.get("time"))));
      return notices;
   }
   private void addPendingNotice(List<Map<String, Object>> notices, String id, String category, String template, String path, String sql) {
      Map<String, Object> summary = DatabaseRows.normalize(jdbcTemplate.queryForMap(sql));
      long count = summary.get("pendingCount") instanceof Number number ? number.longValue() : 0;
      if (count == 0) return;
      Map<String, Object> notice = new LinkedHashMap<>();
      notice.put("id", id);
      notice.put("category", category);
      notice.put("content", template.formatted(count));
      notice.put("time", summary.get("latestTime"));
      notice.put("path", path);
      notices.add(notice);
   }
   @Transactional(readOnly = true)
   public Map<String, Object> merchant(Long id) {
      Map<String, Object> result = one("""
            select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url,
                   address, business_status, longitude, latitude, can_login_merchant_app, group_buying_enabled, usage_instructions,
                   rights_text, merchant_rights_config, service_label, is_recommend, sort_no, status, created_at, updated_at,
            select merchant.id, merchant.merchant_name, merchant.merchant_type, merchant.contact_name, merchant.contact_mobile,
                   merchant.logo_url, merchant.cover_url, merchant.address, merchant.business_status, merchant.longitude,
                   merchant.latitude, merchant.can_login_merchant_app, merchant.group_buying_enabled, merchant.usage_instructions,
                   merchant.rights_text, merchant.merchant_rights_config, merchant.service_label, merchant.is_recommend,
                   merchant.sort_no, merchant.status, merchant.created_at, merchant.updated_at,
                   s.external_link_type as car_wash_external_link_type,
                   s.external_mini_program_app_id as car_wash_external_mini_program_app_id,
                   s.external_mini_program_path as car_wash_external_mini_program_path,
                   s.external_web_url as car_wash_external_web_url,
                   (select o.account from merchant_operator o
                    where o.merchant_id = merchant.id and o.role_code = 'owner' and o.deleted = 0
                    order by o.id asc limit 1) as login_account
            from merchant
            where deleted = 0 and id = ?
            left join transport_car_wash_service s on s.merchant_id = merchant.id and s.deleted = 0
            where merchant.deleted = 0 and merchant.id = ?
            """, id);
      result.put("businessHours", DatabaseRows.normalize(jdbcTemplate.queryForList("""
            select start_time, end_time
@@ -317,7 +362,8 @@
   @Transactional
   public Map<String, Object> createMerchant(MerchantCreateRequest request) {
      validateScenicMerchantCreate(request);
      validateMerchantAddress(request.merchantType(), request.address());
      validateRequiredMerchantCreateFields(request);
      jdbcTemplate.update("""
            insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, address,
                                    logo_url, cover_url, business_status, longitude, latitude, can_login_merchant_app, group_buying_enabled,
@@ -343,6 +389,7 @@
            request.recommend() == null ? defaultRecommend(request.merchantType()) : request.recommend(),
            request.sortNo() == null ? 0 : request.sortNo());
      Long id = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
      ensureParkingBenefit(id, request.merchantType());
      Long storeId = createDefaultStore(id, request.merchantName(), request.address());
      syncMerchantBusinessHours(id, storeId, request.businessHours());
      if (hasMerchantAppAccess(request)) {
@@ -363,13 +410,23 @@
      return one("select * from merchant where id = ?", id);
   }
   void validateMerchantAddress(String merchantType, String address) {
      if (!"公寓租赁".equals(merchantType) && (address == null || address.isBlank())) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入商家地址");
      }
   }
   private boolean hasMerchantAppAccess(MerchantCreateRequest request) {
      return ("餐饮服务".equals(request.merchantType()) || "景区服务".equals(request.merchantType()))
      return supportsMerchantAppLogin(request.merchantType())
            && (request.canLoginMerchantApp() == null || request.canLoginMerchantApp() == 1);
   }
   private void validateScenicMerchantCreate(MerchantCreateRequest request) {
      if (!"景区服务".equals(request.merchantType())) {
   boolean supportsMerchantAppLogin(String merchantType) {
      return MERCHANT_APP_LOGIN_TYPES.contains(merchantType);
   }
   private void validateRequiredMerchantCreateFields(MerchantCreateRequest request) {
      if (!Set.of("景区服务", "餐饮服务").contains(request.merchantType())) {
         return;
      }
      if (request.businessHours() == null || request.businessHours().isEmpty()
@@ -457,9 +514,52 @@
            request.status() == null ? 1 : request.status(),
            id);
      ensureUpdated(updated);
      ensureParkingBenefit(id, request.merchantType());
      Long storeId = findOrCreateDefaultStore(id, request.merchantName(), request.address());
      syncMerchantBusinessHours(id, storeId, request.businessHours());
      if (request.loginPassword() != null && !request.loginPassword().isBlank()) {
         if (request.loginPassword().length() < 6 || request.loginPassword().length() > 64) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录密码长度须为6至64位");
         }
         int passwordUpdated = jdbcTemplate.update("""
               update merchant_operator
               set password_hash = ?
               where merchant_id = ? and role_code = 'owner' and deleted = 0
               """, MerchantPasswordHasher.encode(request.loginPassword()), id);
         if (passwordUpdated == 0) {
            createMerchantOperator(id, request);
         }
      }
      if ((request.loginPassword() == null || request.loginPassword().isBlank()) && supportsMerchantAppLogin(request.merchantType())
            && (request.canLoginMerchantApp() == null || request.canLoginMerchantApp() == 1)
            && (request.loginAccount() != null && !request.loginAccount().isBlank())) {
         createMerchantOperator(id, request);
      }
      return merchant(id);
   }
   private void createMerchantOperator(Long merchantId, MerchantUpdateRequest request) {
      if (request.loginAccount() == null || !request.loginAccount().trim().matches("^[A-Za-z0-9]{1,32}$")) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录账号只能包含英文字母和数字");
      }
      if (request.loginPassword() == null || request.loginPassword().length() < 6 || request.loginPassword().length() > 64) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录密码长度须为6至64位");
      }
      Integer occupied = jdbcTemplate.queryForObject("select count(*) from merchant_operator where account = ? and deleted = 0", Integer.class, request.loginAccount().trim());
      if (occupied != null && occupied > 0) throw new BusinessException(ErrorCode.CONFLICT, "登录账号已存在");
      jdbcTemplate.update("insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status) values (?, ?, ?, ?, 'owner', 1)",
            merchantId, request.loginAccount().trim(), MerchantPasswordHasher.encode(request.loginPassword()), request.merchantName().trim());
   }
   private void ensureParkingBenefit(Long merchantId, String merchantType) {
      if (!"停车服务".equals(merchantType) && !"parking".equalsIgnoreCase(merchantType)) return;
      jdbcTemplate.update("""
            insert into transport_parking_benefit(merchant_id, talent_levels, benefit_text, action_mode, plate_form_mode, usage_instructions, rule_text, status, deleted)
            select ?, '', '停车权益', 'plate', 'withParking',
                   '登记车牌后可享受当前停车场的人才专属停车权益。',
                   '具体免费时长、适用时段和停车场范围以现场说明为准。', 1, 0
            where not exists (select 1 from transport_parking_benefit where merchant_id = ? and deleted = 0)
            """, merchantId, merchantId);
   }
   @Transactional
@@ -586,7 +686,8 @@
   @Transactional
   public Map<String, Object> createProduct(ProductCreateRequest request) {
      ensureMerchantExists(request.merchantId());
      validateProductRights(request.rightsConfig());
      validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages());
      validateProductRights(request.merchantId(), request.rightsConfig());
      String productNo = BusinessIdGenerator.next("P");
      BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount());
      jdbcTemplate.update("""
@@ -620,7 +721,8 @@
   @Transactional
   public Map<String, Object> updateProduct(Long id, ProductUpdateRequest request) {
      ensureMerchantExists(request.merchantId());
      validateProductRights(request.rightsConfig());
      validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages());
      validateProductRights(request.merchantId(), request.rightsConfig());
      BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount());
      int updated = jdbcTemplate.update("""
            update merchant_product
@@ -703,7 +805,7 @@
   ) {
      StringBuilder sql = new StringBuilder("""
            select c.id, c.merchant_id, m.merchant_name, c.coupon_name, c.condition_type,
                   c.condition_amount, c.coupon_amount, c.talent_levels, c.valid_start, c.valid_end,
                   c.condition_amount, c.coupon_amount, c.talent_levels, c.talent_level_quantities, c.valid_start, c.valid_end,
                   c.product_scope, c.store_consume, c.government_subsidy, c.use_count, c.status,
                   c.created_at, c.updated_at,
                   (select count(*) from merchant_coupon_product cp where cp.coupon_id = c.id) as product_count,
@@ -755,7 +857,7 @@
   public Map<String, Object> coupon(Long id) {
      Map<String, Object> result = one("""
            select c.id, c.merchant_id, m.merchant_name, c.coupon_name, c.condition_type,
                   c.condition_amount, c.coupon_amount, c.talent_levels, c.valid_start, c.valid_end,
                   c.condition_amount, c.coupon_amount, c.talent_levels, c.talent_level_quantities, c.valid_start, c.valid_end,
                   c.product_scope, c.store_consume, c.government_subsidy, c.use_count, c.status,
                   c.created_at, c.updated_at
            from merchant_coupon c
@@ -771,11 +873,13 @@
   public Map<String, Object> createCoupon(CouponCreateRequest request) {
      ensureMerchantExists(request.merchantId());
      validateCouponConfiguration(request.validStart(), request.validEnd(), request.productScope(), request.productIds());
      validateCouponAmount(request.couponAmount());
      validateTalentLevelQuantities(request.talentLevelQuantities());
      jdbcTemplate.update("""
            insert into merchant_coupon(merchant_id, coupon_name, condition_type, condition_amount,
                                        coupon_amount, talent_levels, valid_start, valid_end,
                                        coupon_amount, talent_levels, talent_level_quantities, valid_start, valid_end,
                                        product_scope, store_consume, government_subsidy, status)
            values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
            values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
            """,
            request.merchantId(),
            request.couponName(),
@@ -783,6 +887,7 @@
            request.conditionAmount(),
            valueOrZero(request.couponAmount()),
            joinTalentLevels(request.talentLevels()),
            levelQuantitiesJson(request.talentLevelQuantities()),
            blankToNull(request.validStart()),
            blankToNull(request.validEnd()),
            normalizeProductScope(request.productScope()),
@@ -797,10 +902,12 @@
   public Map<String, Object> updateCoupon(Long id, CouponUpdateRequest request) {
      ensureMerchantExists(request.merchantId());
      validateCouponConfiguration(request.validStart(), request.validEnd(), request.productScope(), request.productIds());
      validateCouponAmount(request.couponAmount());
      validateTalentLevelQuantities(request.talentLevelQuantities());
      int updated = jdbcTemplate.update("""
            update merchant_coupon
            set merchant_id = ?, coupon_name = ?, condition_type = ?, condition_amount = ?,
                coupon_amount = ?, talent_levels = ?, valid_start = ?, valid_end = ?,
                coupon_amount = ?, talent_levels = ?, talent_level_quantities = ?, valid_start = ?, valid_end = ?,
                product_scope = ?, store_consume = ?, government_subsidy = ?, status = ?
            where deleted = 0 and id = ?
            """,
@@ -810,6 +917,7 @@
            request.conditionAmount(),
            valueOrZero(request.couponAmount()),
            joinTalentLevels(request.talentLevels()),
            levelQuantitiesJson(request.talentLevelQuantities()),
            blankToNull(request.validStart()),
            blankToNull(request.validEnd()),
            normalizeProductScope(request.productScope()),
@@ -862,6 +970,41 @@
      return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
   }
   private void applyBusinessStatus(List<Map<String, Object>> merchants) {
      if (merchants.isEmpty()) return;
      List<Long> merchantIds = merchants.stream().map(row -> Long.valueOf(String.valueOf(row.get("id")))).toList();
      String placeholders = String.join(",", java.util.Collections.nCopies(merchantIds.size(), "?"));
      List<Map<String, Object>> periods = DatabaseRows.normalize(jdbcTemplate.queryForList("""
            select h.merchant_id, h.start_time, h.end_time
            from merchant_business_hours h
            join merchant_store s on s.id = h.store_id
            where s.status = 1 and s.deleted = 0 and h.merchant_id in (""" + placeholders + ")", merchantIds.toArray()));
      LocalTime now = LocalTime.now(ZoneId.of("Asia/Shanghai"));
      Set<Long> openMerchantIds = new HashSet<>();
      for (Map<String, Object> period : periods) {
         if (isWithinBusinessPeriod(now, String.valueOf(period.get("startTime")), String.valueOf(period.get("endTime")))) {
            openMerchantIds.add(Long.valueOf(String.valueOf(period.get("merchantId"))));
         }
      }
      for (Map<String, Object> merchant : merchants) {
         Long merchantId = Long.valueOf(String.valueOf(merchant.get("id")));
         boolean enabled = "1".equals(String.valueOf(merchant.get("status")));
         merchant.put("businessStatus", enabled && openMerchantIds.contains(merchantId) ? "营业中" : "休息中");
      }
   }
   private boolean isWithinBusinessPeriod(LocalTime now, String startValue, String endValue) {
      try {
         LocalTime start = LocalTime.parse(startValue);
         LocalTime end = LocalTime.parse(endValue);
         return start.isBefore(end)
               ? !now.isBefore(start) && now.isBefore(end)
               : !now.isBefore(start) || now.isBefore(end);
      } catch (DateTimeParseException ignored) {
         return false;
      }
   }
   private void ensureUpdated(int updated) {
      if (updated == 0) {
         throw new BusinessException(ErrorCode.NOT_FOUND, "Data not found");
@@ -881,6 +1024,17 @@
   private BigDecimal valueOrZero(BigDecimal value) {
      return value == null ? BigDecimal.ZERO : value;
   }
   private void validateCouponAmount(BigDecimal value) {
      if (value == null || value.compareTo(new BigDecimal("0.1")) < 0 || value.compareTo(new BigDecimal("9999.9")) > 0) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券金额必须在0.1-9999.9元之间");
      }
   }
   private void validateTalentLevelQuantities(Map<String, Integer> values) {
      if (values == null) return;
      values.values().forEach(value -> { if (value == null || value < 1 || value > 99) throw new BusinessException(ErrorCode.BAD_REQUEST, "人才卡发放数量必须为1-99"); });
   }
   private String levelQuantitiesJson(Map<String, Integer> values) { return values == null || values.isEmpty() ? null : JSON.toJSONString(values); }
   private String toJson(Object value) {
      if (value == null) {
@@ -935,8 +1089,11 @@
      return "ALL".equalsIgnoreCase(value) ? "ALL" : "SELECTED";
   }
   private void validateProductRights(List<Map<String, Object>> rightsConfig) {
   private void validateProductRights(Long merchantId, List<Map<String, Object>> rightsConfig) {
      if (rightsConfig == null) {
         return;
      }
      if (isHotelMerchant(merchantId) || isHealthCheckMerchant(merchantId)) {
         return;
      }
      for (Map<String, Object> right : rightsConfig) {
@@ -953,6 +1110,39 @@
            throw new BusinessException(ErrorCode.BAD_REQUEST, "免费次数必须为 1 至 999 的整数");
         }
      }
   }
   private void validateProductConfiguration(Long merchantId, BigDecimal salePrice, List<String> productImages) {
      if (isHealthCheckMerchant(merchantId)) {
         return;
      }
      if (salePrice == null) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "商品金额不能为空");
      }
      if (productImages == null || productImages.isEmpty()) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "请至少添加一张商品图片");
      }
   }
   private boolean isHotelMerchant(Long merchantId) {
      String type = merchantType(merchantId);
      return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type);
   }
   private boolean isHealthCheckMerchant(Long merchantId) {
      String type = merchantType(merchantId);
      return "healthCheck".equalsIgnoreCase(type) || "免费体检".equals(type);
   }
   private String merchantType(Long merchantId) {
      List<String> types = jdbcTemplate.queryForList(
            "select merchant_type from merchant where deleted = 0 and id = ? limit 1",
            String.class,
            merchantId);
      if (types.isEmpty()) {
         return "";
      }
      return types.get(0) == null ? "" : types.get(0).trim();
   }
   private void validateCouponConfiguration(String validStart, String validEnd, String productScope, List<Long> productIds) {
@@ -982,10 +1172,16 @@
         return null;
      }
      Safelist safelist = Safelist.relaxed()
            .removeTags("style", "script")
            .removeTags("script")
            .addTags("span", "video", "source")
            .addAttributes(":all", "style", "class")
            .addAttributes("a", "target")
            .addProtocols("a", "href", "https")
            .addProtocols("img", "src", "https");
            .addAttributes("video", "src", "controls", "poster", "width", "height")
            .addAttributes("source", "src", "type")
            .addProtocols("a", "href", "http", "https")
            .addProtocols("img", "src", "http", "https")
            .addProtocols("video", "src", "http", "https")
            .addProtocols("source", "src", "http", "https");
      return Jsoup.clean(content, safelist);
   }