yeg
2026-09-18 70ea0911ce7519ca802266078624e9fed3559a2c
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -2,9 +2,12 @@
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;
@@ -14,6 +17,8 @@
import com.alibaba.fastjson.JSON;
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.business.merchant.auth.MerchantPasswordHasher;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -25,10 +30,6 @@
@Service
public class AdminBusinessService {
   private static final Set<String> MERCHANT_APP_LOGIN_TYPES = Set.of(
         "餐饮服务", "景区服务", "数码服务", "文化服务", "电信服务", "口腔治疗保健", "家电服务"
   );
   private final JdbcTemplate jdbcTemplate;
@@ -43,7 +44,7 @@
      result.put("cardCount", count("select count(*) from talent_card where deleted = 0"));
      result.put("merchantCount", count("select count(*) from merchant where deleted = 0"));
      result.put("orderCount", count("select count(*) from benefit_order where deleted = 0"));
      result.put("pendingProductCount", count("select count(*) from merchant_product where deleted = 0 and audit_status = 1"));
      result.put("pendingProductCount", count("select count(*) from merchant_product_audit_record where deleted = 0 and audit_status = 1"));
      result.put("todayVerifyCount", count("select count(*) from verify_record where date(verify_time) = current_date"));
      return result;
   }
@@ -93,7 +94,7 @@
         countSql.append(" and date(publish_time) <= ?");
         args.add(publishEnd);
      }
      sql.append(" order by is_top desc, case when is_top = 1 then top_time end desc, publish_time desc, id desc");
      sql.append(" order by is_top desc, case when is_top = 1 then updated_at end desc, publish_time desc, id desc");
      return page(sql.toString(), countSql.toString(), page, size, args.toArray());
   }
@@ -179,28 +180,45 @@
      if (status != null) { sql.append(" and status = ?"); countSql.append(" and status = ?"); args.add(status); }
      if (publishStart != null && !publishStart.isBlank()) { sql.append(" and date(publish_time) >= ?"); countSql.append(" and date(publish_time) >= ?"); args.add(publishStart); }
      if (publishEnd != null && !publishEnd.isBlank()) { sql.append(" and date(publish_time) <= ?"); 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 updated_at end desc, sort_no desc, publish_time desc, id desc");
      return page(sql.toString(), countSql.toString(), page, size, args.toArray());
   }
   @Transactional(readOnly = true)
   public Map<String, Object> policyDetail(Long id) {
      return one("select id, policy_no, title, subtitle, cover_url, author, source, content, view_count, is_top, sort_no, publish_time, status, created_at, updated_at from policy_article where deleted = 0 and id = ?", id);
      // created_by/updated_by 存的是管理员 ID,这里一并查出姓名,前端直接展示发布人/修改人。
      // 历史数据没有记录操作人时回落到 author(创建人)。
      return one("""
            select p.id, p.policy_no, p.title, p.subtitle, p.cover_url, p.author,
                   p.created_by, p.updated_by, p.is_top, p.top_time,
                   coalesce(creator.display_name, p.author) as created_by_name,
                   coalesce(updater.display_name, p.author) as updated_by_name,
                   p.source, p.content, p.view_count, p.sort_no, p.publish_time, p.status,
                   p.created_at, p.updated_at
            from policy_article p
            left join admin_operator creator on creator.id = p.created_by and creator.deleted = 0
            left join admin_operator updater on updater.id = p.updated_by and updater.deleted = 0
            where p.deleted = 0 and p.id = ?
            """, id);
   }
   @Transactional
   public Map<String, Object> createPolicy(PolicyArticleRequest request, String operatorName) {
   public Map<String, Object> createPolicy(PolicyArticleRequest request, Long operatorId, 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(operatorName), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status);
      // top_time 只在勾选置顶时写入,列表用它做置顶组内的排序依据。
      jdbcTemplate.update("insert into policy_article(policy_no, title, subtitle, cover_url, author, created_by, updated_by, source, content, is_top, top_time, 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(operatorName), operatorId, operatorId, blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, 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));
   }
   @Transactional
   public Map<String, Object> updatePolicy(Long id, PolicyArticleRequest request) {
   public Map<String, Object> updatePolicy(Long id, PolicyArticleRequest request, Long operatorId) {
      int status = defaultNewsStatus(request.status());
      int updated = jdbcTemplate.update("update policy_article set title = ?, subtitle = ?, cover_url = ?, author = ?, source = ?, content = ?, 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.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, id);
      // author 保持原样(发布人只在创建时确定);updated_by 记录本次保存的管理员,空值不覆盖。
      // 新勾选置顶时记录置顶时间;取消置顶时清空,保证置顶组内按"最近置顶"排序。
      int updated = jdbcTemplate.update("update policy_article set title = ?, subtitle = ?, cover_url = ?, author = coalesce(nullif(?, ''), author), updated_by = coalesce(?, updated_by), source = ?, content = ?, is_top = ?, top_time = case when ? = 1 and coalesce(is_top, 0) = 0 then now() when ? = 0 then null else top_time end, 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.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), operatorId, blankToNull(request.source()), 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, status, id);
      ensureUpdated(updated);
      return policyDetail(id);
   }
@@ -225,23 +243,11 @@
         int size
   ) {
      StringBuilder sql = new StringBuilder("""
            select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile,
            select m.id, m.merchant_name, m.merchant_type, m.merchant_type_code, 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, m.merchant_rights_config,
                   (select s.external_link_type from transport_car_wash_service s
                    where s.merchant_id = m.id and s.deleted = 0 order by s.id desc limit 1) as car_wash_external_link_type,
                   (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
@@ -251,11 +257,9 @@
      StringBuilder countSql = new StringBuilder("select count(*) from merchant m where m.deleted = 0");
      List<Object> args = new ArrayList<>();
      if (keyword != null && !keyword.isBlank()) {
         sql.append(" and (m.merchant_name like ? or m.contact_mobile like ? or m.address like ?)");
         countSql.append(" and (m.merchant_name like ? or m.contact_mobile like ? or m.address like ?)");
         sql.append(" and m.merchant_name like ?");
         countSql.append(" and m.merchant_name like ?");
         String like = "%" + keyword.trim() + "%";
         args.add(like);
         args.add(like);
         args.add(like);
      }
      if (contactMobile != null && !contactMobile.isBlank()) {
@@ -264,9 +268,9 @@
         args.add("%" + contactMobile.trim() + "%");
      }
      if (type != null && !type.isBlank()) {
         sql.append(" and m.merchant_type = ?");
         countSql.append(" and m.merchant_type = ?");
         args.add(type);
         sql.append(" and m.merchant_type_code = ?");
         countSql.append(" and m.merchant_type_code = ?");
         args.add(MerchantTypeCatalog.canonicalCode(type));
      }
      if (status != null) {
         sql.append(" and m.status = ?");
@@ -288,8 +292,45 @@
         countSql.append(" and date(m.created_at) <= ?");
         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());
      sql.append(" order by m.sort_no desc, m.created_at desc, m.id desc");
      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_audit_record 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, "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)
@@ -299,14 +340,14 @@
                   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,
                   merchant.sort_no, merchant.status, merchant.created_at, merchant.updated_at, merchant.merchant_type_code,
                   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
                    where o.merchant_id = merchant.id
                    order by o.deleted asc, case when o.role_code = 'owner' then 0 else 1 end, o.id asc limit 1) as login_account
            from merchant
            left join transport_car_wash_service s on s.merchant_id = merchant.id and s.deleted = 0
            where merchant.deleted = 0 and merchant.id = ?
@@ -317,32 +358,70 @@
            where merchant_id = ?
            order by sort_no, id
            """, id)));
      if ("fuel".equals(String.valueOf(result.get("merchantTypeCode")))) {
         result.put("applicableStores", DatabaseRows.normalize(jdbcTemplate.queryForList("""
               select id, store_name, address, longitude, latitude
               from transport_fuel_store
               where merchant_id = ? and deleted = 0 and enabled = 1
               order by sort_no asc, id asc
               """, id)));
         // 加油服务详情页顶部轮播图:后台在商家编辑页维护,保存时整表替换。
         result.put("heroImages", DatabaseRows.normalize(jdbcTemplate.queryForList("""
               select id, image_url, sort_no, enabled
               from transport_fuel_hero_image
               where merchant_id = ? and deleted = 0
               order by sort_no asc, id asc
               """, id)));
      } else if (HERO_IMAGE_MERCHANT_TYPES.contains(String.valueOf(result.get("merchantTypeCode")))) {
         // 子女托管 / 行政审批的详情页轮播图同样按商家配置。
         result.put("heroImages", DatabaseRows.normalize(jdbcTemplate.queryForList("""
               select id, image_url, sort_no, enabled
               from merchant_hero_image
               where merchant_id = ? and deleted = 0
               order by sort_no asc, id asc
               """, id)));
      }
      return result;
   }
   @Transactional(readOnly = true)
   public Map<String, Object> merchantLoginAccountAvailable(String account) {
      return merchantLoginAccountAvailable(account, null);
   }
   @Transactional(readOnly = true)
   public Map<String, Object> merchantLoginAccountAvailable(String account, Long merchantId) {
      String normalizedAccount = account == null ? "" : account.trim();
      if (!normalizedAccount.matches("^[A-Za-z0-9]{1,32}$")) {
         return Map.of("available", false);
      }
      Long count = jdbcTemplate.queryForObject(
            "select count(*) from merchant_operator where account = ?", Long.class, normalizedAccount);
      Long count;
      if (merchantId == null) {
         count = jdbcTemplate.queryForObject(
               "select count(*) from merchant_operator where account = ?", Long.class, normalizedAccount);
      } else {
         count = jdbcTemplate.queryForObject(
               "select count(*) from merchant_operator where account = ? and merchant_id <> ?",
               Long.class, normalizedAccount, merchantId);
      }
      return Map.of("available", count == null || count == 0);
   }
   @Transactional
   public Map<String, Object> createMerchant(MerchantCreateRequest request) {
      validateMerchantAddress(request.merchantType(), request.address());
      validateScenicMerchantCreate(request);
      MerchantType merchantType = requireMerchantType(request.merchantType());
      validateMerchantAddress(merchantType.code(), request.address());
      validateRequiredMerchantCreateFields(request);
      validateMerchantRightsConfig(merchantType.code(), request.merchantRightsConfig());
      jdbcTemplate.update("""
            insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, address,
            insert into merchant(merchant_name, merchant_type, merchant_type_code, contact_name, contact_mobile, address,
                                    logo_url, cover_url, business_status, longitude, latitude, can_login_merchant_app, group_buying_enabled,
                                    usage_instructions, rights_text, merchant_rights_config, service_label, is_recommend, status, sort_no)
            values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
            values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
            """,
            request.merchantName(),
            request.merchantType(),
            merchantType.displayName(),
            merchantType.code(),
            request.contactName(),
            request.contactMobile(),
            request.address(),
@@ -357,13 +436,20 @@
            request.rightsText(),
            toJson(request.merchantRightsConfig()),
            request.serviceLabel(),
            request.recommend() == null ? defaultRecommend(request.merchantType()) : request.recommend(),
            request.recommend() == null ? defaultRecommend(merchantType.code()) : request.recommend(),
            request.sortNo() == null ? 0 : request.sortNo());
      Long id = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
      ensureParkingBenefit(id, merchantType.code());
      ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
      syncFuelStores(id, merchantType.code(), request.applicableStores());
      syncMerchantHeroImages(id, merchantType.code(), request.heroImages());
      syncPremiumTopicMerchant(id, merchantType, request.sortNo());
      Long storeId = createDefaultStore(id, request.merchantName(), request.address());
      syncMerchantBusinessHours(id, storeId, request.businessHours());
      if (hasMerchantAppAccess(request)) {
         String account = request.loginAccount().trim();
      if (hasMerchantAppAccess(request)
            || (request.loginAccount() != null && !request.loginAccount().isBlank())
            || (request.loginPassword() != null && !request.loginPassword().isBlank())) {
         String account = request.loginAccount() == null ? "" : request.loginAccount().trim();
         if (!account.matches("^[A-Za-z0-9]{1,32}$")) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录账号只能包含英文字母和数字");
         }
@@ -381,7 +467,7 @@
   }
   void validateMerchantAddress(String merchantType, String address) {
      if (!"公寓租赁".equals(merchantType) && (address == null || address.isBlank())) {
      if (!"apartment".equals(MerchantTypeCatalog.canonicalCode(merchantType)) && (address == null || address.isBlank())) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入商家地址");
      }
   }
@@ -392,23 +478,34 @@
   }
   boolean supportsMerchantAppLogin(String merchantType) {
      return MERCHANT_APP_LOGIN_TYPES.contains(merchantType);
      return MerchantTypeCatalog.find(merchantType)
            .map(MerchantType::supportsMerchantAppLogin)
            .orElse(false);
   }
   private void validateScenicMerchantCreate(MerchantCreateRequest request) {
      if (!"景区服务".equals(request.merchantType())) {
   private void validateRequiredMerchantCreateFields(MerchantCreateRequest request) {
      if (!Set.of("scenic", "food").contains(MerchantTypeCatalog.canonicalCode(request.merchantType()))) {
         return;
      }
      if (request.businessHours() == null || request.businessHours().isEmpty()
            || request.businessHours().stream().anyMatch(item -> item.startTime() == null || item.endTime() == null
                  || item.startTime().isBlank() || item.endTime().isBlank() || item.startTime().equals(item.endTime()))) {
         throw new BusinessException(ErrorCode.BAD_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位");
      }
   }
   private void validateMerchantRightsConfig(String merchantType, List<Map<String, Object>> rightsConfig) {
      if (!Set.of("food", "scenic").contains(MerchantTypeCatalog.canonicalCode(merchantType)) || rightsConfig == null) return;
      for (Map<String, Object> right : rightsConfig) {
         Object storeConsume = right.get("storeConsume");
         if (!Boolean.TRUE.equals(storeConsume) && !"true".equalsIgnoreCase(String.valueOf(storeConsume))) continue;
         String discount = String.valueOf(right.getOrDefault("discount", "")).trim();
         if (!discount.matches("^(?:[0-9]|10)(?:\\.\\d)?$")
               || new BigDecimal(discount).compareTo(BigDecimal.ZERO) <= 0
               || new BigDecimal(discount).compareTo(BigDecimal.TEN) > 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "折扣请输入大于0且不超过10的一位小数");
         }
      }
   }
@@ -433,13 +530,13 @@
   private void syncMerchantBusinessHours(Long merchantId, Long storeId, List<MerchantBusinessHourRequest> businessHours) {
      if (businessHours == null) return;
      jdbcTemplate.update("delete from merchant_business_hours where merchant_id = ? and store_id = ?", merchantId, storeId);
      if (businessHours.isEmpty()) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "请至少配置一个营业时间段");
         return;
      }
      if (businessHours.stream().anyMatch(item -> item.startTime().equals(item.endTime()))) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "营业开始时间和结束时间不能相同");
      }
      jdbcTemplate.update("delete from merchant_business_hours where merchant_id = ? and store_id = ?", merchantId, storeId);
      for (int index = 0; index < businessHours.size(); index++) {
         MerchantBusinessHourRequest item = businessHours.get(index);
         jdbcTemplate.update("""
@@ -450,21 +547,25 @@
   }
   private byte defaultRecommend(String merchantType) {
      return (byte) (("餐饮服务".equals(merchantType) || "景区服务".equals(merchantType)) ? 1 : 0);
      return (byte) (("food".equals(merchantType) || "scenic".equals(merchantType)) ? 1 : 0);
   }
   @Transactional
   public Map<String, Object> updateMerchant(Long id, MerchantUpdateRequest request) {
      MerchantType merchantType = resolveMerchantTypeForUpdate(id, request.merchantType());
      validateMerchantAddress(merchantType.code(), request.address());
      validateMerchantRightsConfig(merchantType.code(), request.merchantRightsConfig());
      int updated = jdbcTemplate.update("""
            update merchant
            set merchant_name = ?, merchant_type = ?, contact_name = ?, contact_mobile = ?,
            set merchant_name = ?, merchant_type = ?, merchant_type_code = ?, contact_name = ?, contact_mobile = ?,
                address = ?, logo_url = ?, cover_url = ?, 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 = ?
            where deleted = 0 and id = ?
            """,
            request.merchantName(),
            request.merchantType(),
            merchantType.displayName(),
            merchantType.code(),
            request.contactName(),
            request.contactMobile(),
            request.address(),
@@ -484,6 +585,11 @@
            request.status() == null ? 1 : request.status(),
            id);
      ensureUpdated(updated);
      ensureParkingBenefit(id, merchantType.code());
      ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
      syncFuelStores(id, merchantType.code(), request.applicableStores());
      syncMerchantHeroImages(id, merchantType.code(), request.heroImages());
      syncPremiumTopicMerchant(id, merchantType, request.sortNo());
      Long storeId = findOrCreateDefaultStore(id, request.merchantName(), request.address());
      syncMerchantBusinessHours(id, storeId, request.businessHours());
      if (request.loginPassword() != null && !request.loginPassword().isBlank()) {
@@ -492,12 +598,214 @@
         }
         int passwordUpdated = jdbcTemplate.update("""
               update merchant_operator
               set password_hash = ?
               where merchant_id = ? and role_code = 'owner' and deleted = 0
               set password_hash = ?, deleted = 0, status = 1
               where merchant_id = ? and role_code = 'owner'
               """, MerchantPasswordHasher.encode(request.loginPassword()), id);
         if (passwordUpdated == 0) throw new BusinessException(ErrorCode.CONFLICT, "该商家未配置商户端登录账号");
         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())) {
         ensureMerchantOperator(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 = ?", 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 ensureMerchantOperator(Long merchantId, MerchantUpdateRequest request) {
      String account = request.loginAccount().trim();
      Integer sameMerchant = jdbcTemplate.queryForObject(
            "select count(*) from merchant_operator where merchant_id = ? and account = ?",
            Integer.class, merchantId, account);
      if (sameMerchant != null && sameMerchant > 0) {
         jdbcTemplate.update("""
               update merchant_operator
               set deleted = 0, status = 1, display_name = ?
               where merchant_id = ? and account = ?
               """, request.merchantName().trim(), merchantId, account);
         return;
      }
      createMerchantOperator(merchantId, request);
   }
   private void ensureParkingBenefit(Long merchantId, String merchantType) {
      if (!"parking".equals(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);
   }
   private void ensureFuelBenefit(Long merchantId, String merchantType, String serviceLabel) {
      if (!"fuel".equals(merchantType)) return;
      String discountText = serviceLabel == null || serviceLabel.isBlank() ? "请咨询商户" : serviceLabel.trim();
      jdbcTemplate.update("""
            insert into transport_fuel_benefit(
               merchant_id, talent_levels, discount_text, oil_types, single_limit_text, annual_limit_text,
               available_time, usage_instructions, rule_text, status, deleted
            )
            select ?, '卓越人才卡、杰出人才卡、菁英人才卡', ?, '请咨询商户', '请咨询商户', '请咨询商户',
                   '请咨询商户', '提交手机号后,工作人员将根据加油权益与您联系。',
                   '具体适用油品、优惠额度、可用时段和活动规则以加油站说明为准。', 1, 0
            where not exists (select 1 from transport_fuel_benefit where merchant_id = ? and deleted = 0)
            """, merchantId, discountText, merchantId);
   }
   private void syncFuelStores(Long merchantId, String merchantType, List<Map<String, Object>> stores) {
      if (!"fuel".equals(merchantType) || stores == null) return;
      List<Map<String, Object>> normalized = new ArrayList<>();
      Set<String> names = new HashSet<>();
      for (Map<String, Object> store : stores) {
         if (store == null) continue;
         Object nameValue = store.containsKey("storeName") ? store.get("storeName") : store.get("name");
         String name = nameValue == null ? "" : String.valueOf(nameValue).trim();
         Object addressValue = store.get("address");
         String address = addressValue == null ? "" : String.valueOf(addressValue).trim();
         if (name.isBlank() || address.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请完善适用门店名称和地址");
         if (!names.add(name)) throw new BusinessException(ErrorCode.BAD_REQUEST, "适用门店名称不能重复");
         Double longitude = numberValue(store.get("longitude"));
         Double latitude = numberValue(store.get("latitude"));
         if (longitude == null || longitude < -180 || longitude > 180 || latitude == null || latitude < -90 || latitude > 90
               || (longitude == 0 && latitude == 0)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请完成所有适用门店的标点");
         }
         Map<String, Object> item = new LinkedHashMap<>();
         item.put("name", name);
         item.put("address", address);
         item.put("longitude", longitude);
         item.put("latitude", latitude);
         normalized.add(item);
      }
      jdbcTemplate.update("delete from transport_fuel_store where merchant_id = ?", merchantId);
      for (int index = 0; index < normalized.size(); index++) {
         Map<String, Object> store = normalized.get(index);
         jdbcTemplate.update("""
               insert into transport_fuel_store(merchant_id, store_name, address, longitude, latitude, sort_no, enabled, deleted)
               values (?, ?, ?, ?, ?, ?, 1, 0)
               """, merchantId, store.get("name"), store.get("address"), store.get("longitude"), store.get("latitude"), (index + 1) * 10);
      }
   }
   /**
    * 子女托管 / 行政审批的详情页图:这两类服务的权益是全局一条,图片也全局共用。
    */
   @Transactional(readOnly = true)
   public Map<String, Object> serviceHeroImages() {
      Map<String, Object> result = new LinkedHashMap<>();
      result.put("children", firstRowOrEmpty("select id, hero_image from children_entrustment_benefit where deleted = 0 order by id asc limit 1"));
      result.put("approval", firstRowOrEmpty("select id, hero_image from admin_approval_benefit where deleted = 0 order by id asc limit 1"));
      return result;
   }
   @Transactional
   public Map<String, Object> saveServiceHeroImage(String type, String imageUrl) {
      String table = switch (type == null ? "" : type.trim()) {
         case "children" -> "children_entrustment_benefit";
         case "approval" -> "admin_approval_benefit";
         default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的服务类型");
      };
      String url = imageUrl == null || imageUrl.isBlank() ? null : imageUrl.trim();
      int updated = jdbcTemplate.update("update " + table + " set hero_image = ? where deleted = 0 order by id asc limit 1", url);
      if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "该服务的权益暂未配置");
      return serviceHeroImages();
   }
   private Map<String, Object> firstRowOrEmpty(String sql, Object... args) {
      List<Map<String, Object>> rows = DatabaseRows.normalize(jdbcTemplate.queryForList(sql, args));
      return rows.isEmpty() ? new LinkedHashMap<>() : rows.get(0);
   }
   private boolean enabledFlag(Object value) {
      if (value == null) return true;
      if (value instanceof Boolean bool) return bool;
      if (value instanceof Number number) return number.intValue() != 0;
      String text = String.valueOf(value).trim();
      return "true".equalsIgnoreCase(text) || "1".equals(text);
   }
   private Double numberValue(Object value) {
      if (value instanceof Number number) return number.doubleValue();
      try { return value == null ? null : Double.valueOf(String.valueOf(value)); }
      catch (NumberFormatException ignored) { return null; }
   }
   /** 详情页轮播图按商家配置的服务类型:其余类型顶部图仍用封面图或团单封面。 */
   private static final List<String> HERO_IMAGE_MERCHANT_TYPES = List.of("children", "approval");
   /**
    * 同步商家详情页轮播图。
    *
    * 后台提交整份列表,这里按商家整表替换;列表为 null 表示本次提交没有这个字段,保持原样。
    * 加油服务沿用原有的 transport_fuel_hero_image,子女托管/行政审批使用通用的 merchant_hero_image。
    */
   private void syncMerchantHeroImages(Long merchantId, String merchantType, List<Map<String, Object>> images) {
      if (images == null) return;
      boolean fuel = "fuel".equals(merchantType);
      if (!fuel && !HERO_IMAGE_MERCHANT_TYPES.contains(merchantType)) return;
      List<Map<String, Object>> normalized = new ArrayList<>();
      for (Map<String, Object> image : images) {
         if (image == null) continue;
         Object urlValue = image.containsKey("imageUrl") ? image.get("imageUrl") : image.get("image_url");
         String url = urlValue == null ? "" : String.valueOf(urlValue).trim();
         if (url.isBlank()) continue;
         Map<String, Object> item = new LinkedHashMap<>();
         item.put("imageUrl", url);
         item.put("enabled", enabledFlag(image.get("enabled")) ? 1 : 0);
         normalized.add(item);
      }
      String table = fuel ? "transport_fuel_hero_image" : "merchant_hero_image";
      jdbcTemplate.update("delete from " + table + " where merchant_id = ?", merchantId);
      String insertSql = "insert into " + table + "(merchant_id, image_url, sort_no, enabled, deleted) values (?, ?, ?, ?, 0)";
      for (int index = 0; index < normalized.size(); index++) {
         Map<String, Object> image = normalized.get(index);
         jdbcTemplate.update(insertSql, merchantId, image.get("imageUrl"), (index + 1) * 10, image.get("enabled"));
      }
   }
   private void syncPremiumTopicMerchant(Long merchantId, MerchantType merchantType, Integer sortNo) {
      if (merchantType.premiumCategoryKey() == null || merchantType.premiumBusinessType() == null) {
         jdbcTemplate.update("update premium_topic_merchant set deleted = 1 where merchant_id = ? and deleted = 0", merchantId);
         return;
      }
      jdbcTemplate.update("""
            insert into premium_topic_merchant(merchant_id, category_key, business_type, sort_no, deleted)
            values (?, ?, ?, ?, 0)
            on duplicate key update category_key = values(category_key), business_type = values(business_type),
                                    sort_no = values(sort_no), deleted = 0
            """, merchantId, merchantType.premiumCategoryKey(), merchantType.premiumBusinessType(), sortNo == null ? 0 : sortNo);
   }
   private MerchantType requireMerchantType(String value) {
      return MerchantTypeCatalog.find(value)
            .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "不支持的商家类型"));
   }
   private MerchantType resolveMerchantTypeForUpdate(Long merchantId, String value) {
      MerchantType requested = requireMerchantType(value);
      if (!"shopping".equals(requested.code())) return requested;
      List<String> existingCodes = jdbcTemplate.queryForList(
            "select merchant_type_code from merchant where id = ? and deleted = 0 limit 1",
            String.class,
            merchantId);
      if (existingCodes.isEmpty()) return requested;
      return MerchantTypeCatalog.find(existingCodes.get(0)).orElse(requested);
   }
   @Transactional
@@ -530,6 +838,64 @@
      return one("select * from benefit where id = ?", id);
   }
   /**
    * 商家编辑页面的团单列表。
    *
    * 按商品维度展示(一条团单一行),修改团单只更新原记录的状态为待审核;
    * 审核记录(merchant_product_audit_record)仍只在团单审核页面展示。
    */
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> merchantProducts(
         Long merchantId,
         String keyword,
         Integer auditStatus,
         String createdStart,
         String createdEnd,
         int page,
         int size
   ) {
      StringBuilder sql = new StringBuilder("""
            select p.id, p.id as product_id, p.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                   p.product_no, p.product_type, p.product_name, p.subtitle, p.cover_url,
                   p.original_price, p.sale_price, p.subsidy_amount, p.is_government_subsidy,
                   p.stock, %s, p.detail_text, p.benefit_desc, p.purchase_notice,
                   p.valid_start_time, p.valid_end_time, p.product_images, p.rights_config,
                   p.audit_status, p.audit_remark, p.status, p.sort_no, p.submit_time, p.created_at, p.updated_at
            from merchant_product p
            left join merchant m on m.id = p.merchant_id
            where p.deleted = 0 and p.merchant_id = ?
            """.formatted(salesCountSelect("p.id", "p")));
      StringBuilder countSql = new StringBuilder("""
            select count(*)
            from merchant_product p
            where p.deleted = 0 and p.merchant_id = ?
            """);
      List<Object> args = new ArrayList<>();
      args.add(merchantId);
      if (keyword != null && !keyword.isBlank()) {
         sql.append(" and p.product_name like ?");
         countSql.append(" and p.product_name like ?");
         args.add("%" + keyword.trim() + "%");
      }
      if (auditStatus != null) {
         sql.append(" and p.audit_status = ?");
         countSql.append(" and p.audit_status = ?");
         args.add(auditStatus);
      }
      if (createdStart != null && !createdStart.isBlank()) {
         sql.append(" and date(coalesce(p.submit_time, p.created_at)) >= ?");
         countSql.append(" and date(coalesce(p.submit_time, p.created_at)) >= ?");
         args.add(createdStart);
      }
      if (createdEnd != null && !createdEnd.isBlank()) {
         sql.append(" and date(coalesce(p.submit_time, p.created_at)) <= ?");
         countSql.append(" and date(coalesce(p.submit_time, p.created_at)) <= ?");
         args.add(createdEnd);
      }
      sql.append(" order by coalesce(p.updated_at, p.submit_time, p.created_at) desc, p.id desc");
      return page(sql.toString(), countSql.toString(), page, size, args.toArray());
   }
   @Transactional(readOnly = true)
   public PageResult<Map<String, Object>> products(
         Long merchantId,
@@ -544,26 +910,28 @@
         int size
   ) {
      StringBuilder sql = new StringBuilder("""
            select p.id, p.merchant_id, m.merchant_name, m.merchant_type, m.contact_mobile,
                   p.product_no, p.product_type, p.product_name,
                   p.subtitle, p.cover_url, p.original_price, p.sale_price, p.subsidy_amount,
                   p.is_government_subsidy, p.stock, p.sales_count, p.detail_text, p.benefit_desc,
                   p.purchase_notice, p.valid_start_time, p.valid_end_time, p.product_images, p.rights_config,
                   p.audit_status, p.audit_remark, p.status, p.sort_no, p.submit_time, p.created_at
            from merchant_product p
            left join merchant m on m.id = p.merchant_id
            where p.deleted = 0
            """);
            select r.id, r.product_id, r.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                   r.product_no, r.product_type, r.product_name,
                   r.subtitle, r.cover_url, r.original_price, r.sale_price, r.subsidy_amount,
                   r.is_government_subsidy, r.stock, %s, r.detail_text, r.benefit_desc,
                   r.purchase_notice, r.valid_start_time, r.valid_end_time, r.product_images, r.rights_config,
                   r.audit_status, r.audit_remark, r.audit_by, r.audit_time, r.submit_time,
                   r.source_created_at as created_at, p.status
            from merchant_product_audit_record r
            left join merchant_product p on p.id = r.product_id
            left join merchant m on m.id = r.merchant_id
            where r.deleted = 0
            """.formatted(salesCountSelect("r.product_id", "p")));
      StringBuilder countSql = new StringBuilder("""
            select count(*)
            from merchant_product p
            left join merchant m on m.id = p.merchant_id
            where p.deleted = 0
            from merchant_product_audit_record r
            left join merchant m on m.id = r.merchant_id
            where r.deleted = 0
            """);
      List<Object> args = new ArrayList<>();
      if (merchantId != null) {
         sql.append(" and p.merchant_id = ?");
         countSql.append(" and p.merchant_id = ?");
         sql.append(" and r.merchant_id = ?");
         countSql.append(" and r.merchant_id = ?");
         args.add(merchantId);
      }
      if (merchantName != null && !merchantName.isBlank()) {
@@ -577,53 +945,71 @@
         args.add("%" + contactMobile.trim() + "%");
      }
      if (keyword != null && !keyword.isBlank()) {
         sql.append(" and p.product_name like ?");
         countSql.append(" and p.product_name like ?");
         sql.append(" and r.product_name like ?");
         countSql.append(" and r.product_name like ?");
         String like = "%" + keyword.trim() + "%";
         args.add(like);
      }
      if (type != null && !type.isBlank()) {
         sql.append(" and m.merchant_type = ?");
         countSql.append(" and m.merchant_type = ?");
         args.add(type);
         sql.append(" and m.merchant_type_code = ?");
         countSql.append(" and m.merchant_type_code = ?");
         args.add(MerchantTypeCatalog.canonicalCode(type));
      }
      if (status != null) {
         sql.append(" and p.audit_status = ?");
         countSql.append(" and p.audit_status = ?");
         sql.append(" and r.audit_status = ?");
         countSql.append(" and r.audit_status = ?");
         args.add(status);
      }
      if (createdStart != null && !createdStart.isBlank()) {
         sql.append(" and date(p.created_at) >= ?");
         countSql.append(" and date(p.created_at) >= ?");
         sql.append(" and date(coalesce(r.submit_time, r.created_at)) >= ?");
         countSql.append(" and date(coalesce(r.submit_time, r.created_at)) >= ?");
         args.add(createdStart);
      }
      if (createdEnd != null && !createdEnd.isBlank()) {
         sql.append(" and date(p.created_at) <= ?");
         countSql.append(" and date(p.created_at) <= ?");
         sql.append(" and date(coalesce(r.submit_time, r.created_at)) <= ?");
         countSql.append(" and date(coalesce(r.submit_time, r.created_at)) <= ?");
         args.add(createdEnd);
      }
      sql.append(" order by p.submit_time desc, p.id desc");
      sql.append(" order by coalesce(r.submit_time, r.created_at) desc, r.id desc");
      return page(sql.toString(), countSql.toString(), page, size, args.toArray());
   }
   @Transactional(readOnly = true)
   public Map<String, Object> product(Long id) {
      return one("""
            select p.id, p.merchant_id, m.merchant_name, m.merchant_type, m.contact_mobile,
            select p.id, p.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                   p.product_no, p.product_type, p.product_name,
                   p.subtitle, p.cover_url, p.original_price, p.sale_price, p.subsidy_amount,
                   p.is_government_subsidy, p.stock, p.sales_count, p.detail_text, p.benefit_desc,
                   p.is_government_subsidy, p.stock, %s, p.detail_text, p.benefit_desc,
                   p.purchase_notice, p.valid_start_time, p.valid_end_time, p.product_images, p.rights_config,
                   p.audit_status, p.audit_remark, p.status, p.sort_no, p.submit_time, p.created_at, p.updated_at
            from merchant_product p
            left join merchant m on m.id = p.merchant_id
            where p.deleted = 0 and p.id = ?
            """, id);
            """.formatted(salesCountSelect("p.id", "p")), id);
   }
   @Transactional(readOnly = true)
   public Map<String, Object> productAuditRecord(Long auditRecordId) {
      return one("""
            select r.id, r.product_id, r.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                   r.product_no, r.product_type, r.product_name, r.subtitle, r.cover_url,
                   r.original_price, r.sale_price, r.subsidy_amount, r.is_government_subsidy,
                   r.stock, %s, r.detail_text, r.benefit_desc, r.purchase_notice,
                   r.valid_start_time, r.valid_end_time, r.product_images, r.rights_config,
                   r.audit_status, r.audit_remark, r.audit_by, r.audit_time, r.submit_time,
                   r.source_created_at as created_at, p.status
            from merchant_product_audit_record r
            left join merchant_product p on p.id = r.product_id
            left join merchant m on m.id = r.merchant_id
            where r.deleted = 0 and r.id = ?
            """.formatted(salesCountSelect("r.product_id", "p")), auditRecordId);
   }
   @Transactional
   public Map<String, Object> createProduct(ProductCreateRequest request) {
      ensureMerchantExists(request.merchantId());
      validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages());
      validateProductRights(request.merchantId(), request.rightsConfig());
      String productNo = BusinessIdGenerator.next("P");
      BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount());
@@ -652,12 +1038,15 @@
            blankToNull(request.validEndTime()),
            toJson(request.productImages()),
            toJson(request.rightsConfig()));
      return one("select * from merchant_product where product_no = ?", productNo);
      Long productId = jdbcTemplate.queryForObject("select id from merchant_product where product_no = ?", Long.class, productNo);
      appendProductAuditRecord(productId);
      return product(productId);
   }
   @Transactional
   public Map<String, Object> updateProduct(Long id, ProductUpdateRequest request) {
      ensureMerchantExists(request.merchantId());
      validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages());
      validateProductRights(request.merchantId(), request.rightsConfig());
      BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount());
      int updated = jdbcTemplate.update("""
@@ -689,6 +1078,7 @@
            request.sortNo() == null ? 0 : request.sortNo(),
            id);
      ensureUpdated(updated);
      appendProductAuditRecord(id);
      return product(id);
   }
@@ -713,6 +1103,14 @@
   @Transactional
   public Map<String, Object> auditProduct(Long id, ProductAuditRequest request) {
      List<Map<String, Object>> pendingRecords = DatabaseRows.normalize(jdbcTemplate.queryForList("""
            select id from merchant_product_audit_record
            where product_id = ? and deleted = 0 and audit_status = 1
            order by id desc limit 1
            """, id));
      if (!pendingRecords.isEmpty()) {
         return auditProductAuditRecord(Long.valueOf(String.valueOf(pendingRecords.get(0).get("id"))), request);
      }
      if (request.auditStatus() != 2 && request.auditStatus() != 3) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid audit status");
      }
@@ -727,6 +1125,55 @@
            """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, id);
      ensureUpdated(updated);
      return product(id);
   }
   @Transactional
   public Map<String, Object> auditProductAuditRecord(Long auditRecordId, ProductAuditRequest request) {
      if (request.auditStatus() != 2 && request.auditStatus() != 3) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid audit status");
      }
      Map<String, Object> record = productAuditRecord(auditRecordId);
      Number currentAuditStatus = (Number) record.get("auditStatus");
      if (currentAuditStatus == null || currentAuditStatus.intValue() != 1) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "Product audit record is not pending");
      }
      int updated = jdbcTemplate.update("""
            update merchant_product_audit_record
            set audit_status = ?, audit_remark = ?, audit_time = now()
            where deleted = 0 and id = ? and audit_status = 1
            """, request.auditStatus(), request.auditRemark(), auditRecordId);
      ensureUpdated(updated);
      Long productId = Long.valueOf(String.valueOf(record.get("productId")));
      jdbcTemplate.update("""
            update merchant_product
            set audit_status = ?, audit_remark = ?, audit_time = now(), status = ?
            where deleted = 0 and id = ? and audit_status = 1
            """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, productId);
      return productAuditRecord(auditRecordId);
   }
   private void appendProductAuditRecord(Long productId) {
      jdbcTemplate.update("""
            update merchant_product_audit_record
            set audit_status = 4, audit_remark = coalesce(audit_remark, 'superseded by a new submission')
            where product_id = ? and deleted = 0 and audit_status = 1
            """, productId);
      jdbcTemplate.update("""
            insert into merchant_product_audit_record(
                product_id, merchant_id, product_no, product_type, product_name, subtitle, cover_url,
                original_price, sale_price, subsidy_amount, is_government_subsidy, stock, sales_count,
                detail_text, benefit_desc, purchase_notice, valid_start_time, valid_end_time,
                product_images, rights_config, audit_status, audit_remark, audit_by, submit_time,
                audit_time, source_created_at
            )
            select id, merchant_id, product_no, product_type, product_name, subtitle, cover_url,
                   original_price, sale_price, subsidy_amount, is_government_subsidy, stock, sales_count,
                   detail_text, benefit_desc, purchase_notice, valid_start_time, valid_end_time,
                   product_images, rights_config, audit_status, audit_remark, audit_by, submit_time,
                   audit_time, created_at
            from merchant_product
            where id = ? and deleted = 0
            """, productId);
   }
   @Transactional(readOnly = true)
@@ -785,7 +1232,7 @@
         countSql.append(" and date(c.created_at) <= ?");
         args.add(createdEnd);
      }
      sql.append(" order by c.id desc");
      sql.append(" order by c.created_at desc, c.id desc");
      return page(sql.toString(), countSql.toString(), page, size, args.toArray());
   }
@@ -906,6 +1353,45 @@
      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<>();
      Set<Long> merchantsWithBusinessHours = new HashSet<>();
      for (Map<String, Object> period : periods) {
         Long merchantId = Long.valueOf(String.valueOf(period.get("merchantId")));
         merchantsWithBusinessHours.add(merchantId);
         if (isWithinBusinessPeriod(now, String.valueOf(period.get("startTime")), String.valueOf(period.get("endTime")))) {
            openMerchantIds.add(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")));
         boolean open = !merchantsWithBusinessHours.contains(merchantId) || openMerchantIds.contains(merchantId);
         merchant.put("businessStatus", enabled && open ? "营业中" : "休息中");
      }
   }
   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");
@@ -994,7 +1480,7 @@
      if (rightsConfig == null) {
         return;
      }
      if (isHotelMerchant(merchantId)) {
      if (isHotelMerchant(merchantId) || isHealthCheckMerchant(merchantId)) {
         return;
      }
      for (Map<String, Object> right : rightsConfig) {
@@ -1013,16 +1499,37 @@
      }
   }
   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 false;
         return "";
      }
      String type = types.get(0) == null ? "" : types.get(0).trim();
      return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type);
      return types.get(0) == null ? "" : types.get(0).trim();
   }
   private void validateCouponConfiguration(String validStart, String validEnd, String productScope, List<Long> productIds) {
@@ -1073,6 +1580,18 @@
      return Boolean.TRUE.equals(value == null ? defaultValue : value) ? (byte) 1 : (byte) 0;
   }
   /**
    * 团单销量 = 该商品的有效订单数(已支付,且未取消/未退款)。
    *
    * 商品表上的 sales_count 只在部分流程(如酒店房型预约)里累加,普通团单下单后一直是 0,
    * 后台列表就统计不出销量。这里直接按订单算,同时保留 sales_count 作为下限,兼容历史数据。
    */
   String salesCountSelect(String productIdExpression, String productAlias) {
      return "greatest(coalesce(" + productAlias + ".sales_count, 0), (select count(*) from benefit_order bo"
            + " where bo.deleted = 0 and bo.product_id = " + productIdExpression
            + " and bo.order_status = 1 and (bo.refund_status is null or bo.refund_status = 0))) as sales_count";
   }
   private List<Long> couponProductIds(Long couponId) {
      return jdbcTemplate.queryForList(
            "select product_id from merchant_coupon_product where coupon_id = ? order by id",
@@ -1082,13 +1601,13 @@
   private List<Map<String, Object>> couponProducts(Long couponId) {
      return DatabaseRows.normalize(jdbcTemplate.queryForList("""
            select p.id, p.product_name, p.cover_url, p.sale_price, p.sales_count, p.submit_time, p.created_at,
            select p.id, p.product_name, p.cover_url, p.sale_price, %s, p.submit_time, p.created_at,
                   p.audit_status, p.status
            from merchant_coupon_product cp
            join merchant_product p on p.id = cp.product_id
            where cp.coupon_id = ? and p.deleted = 0
            order by cp.id
            """, couponId));
            """.formatted(salesCountSelect("p.id", "p")), couponId));
   }
   private void syncCouponProducts(Long couponId, List<Long> productIds, String productScope) {