yeg
2026-09-28 85bfb9351803d97a33329d507f30a2f78bf1fc75
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -180,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 top_time 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);
   }
@@ -348,6 +365,21 @@
               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;
   }
@@ -410,6 +442,7 @@
      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());
@@ -555,6 +588,7 @@
      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());
@@ -669,10 +703,82 @@
      }
   }
   /**
    * 子女托管 / 行政审批的详情页图:这两类服务的权益是全局一条,图片也全局共用。
    */
   @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; }
   }
   /** 详情页轮播图按商家配置的服务类型:其余类型顶部图仍用封面图或团单封面。 */
   // 只有子女托管走通用的 merchant_hero_image;加油服务用 transport_fuel_hero_image。
   // 行政审批不再单独配详情页图片(人才端直接用商家封面图),所以不在这里。
   private static final List<String> HERO_IMAGE_MERCHANT_TYPES = List.of("children");
   /**
    * 同步商家详情页轮播图。
    *
    * 后台提交整份列表,这里按商家整表替换;列表为 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) {
@@ -734,6 +840,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,
@@ -751,7 +915,7 @@
            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, r.sales_count, r.detail_text, r.benefit_desc,
                   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
@@ -759,12 +923,18 @@
            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.product_id is null or r.id = (
                     select max(newer.id) from merchant_product_audit_record newer
                     where newer.deleted = 0 and newer.product_id = r.product_id))
            """.formatted(salesCountSelect("r.product_id", "p")));
      StringBuilder countSql = new StringBuilder("""
            select count(*)
            from merchant_product_audit_record r
            left join merchant m on m.id = r.merchant_id
            where r.deleted = 0
              and (r.product_id is null or r.id = (
                     select max(newer.id) from merchant_product_audit_record newer
                     where newer.deleted = 0 and newer.product_id = r.product_id))
            """);
      List<Object> args = new ArrayList<>();
      if (merchantId != null) {
@@ -818,13 +988,13 @@
            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)
@@ -833,7 +1003,7 @@
            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, r.sales_count, r.detail_text, r.benefit_desc, r.purchase_notice,
                   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
@@ -841,7 +1011,7 @@
            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 = ?
            """, auditRecordId);
            """.formatted(salesCountSelect("r.product_id", "p")), auditRecordId);
   }
   @Transactional
@@ -886,16 +1056,17 @@
      ensureMerchantExists(request.merchantId());
      validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages());
      validateProductRights(request.merchantId(), request.rightsConfig());
      Long productId = resolveProductId(id);
      BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount());
      int updated = jdbcTemplate.update("""
            update merchant_product
            set merchant_id = ?, product_type = ?, product_name = ?, subtitle = ?, cover_url = ?,
                original_price = ?, sale_price = ?, subsidy_amount = ?, is_government_subsidy = ?,
                stock = ?, detail_text = ?, benefit_desc = ?, purchase_notice = ?, valid_start_time = ?,
                valid_end_time = ?, product_images = ?, rights_config = ?, status = 0, sort_no = ?,
                audit_status = 1, audit_remark = null, audit_time = null, submit_time = now()
            where deleted = 0 and id = ?
            """,
         int updated = jdbcTemplate.update("""
               update merchant_product
               set merchant_id = ?, product_type = ?, product_name = ?, subtitle = ?, cover_url = ?,
                   original_price = ?, sale_price = ?, subsidy_amount = ?, is_government_subsidy = ?,
                   stock = ?, detail_text = ?, benefit_desc = ?, purchase_notice = ?, valid_start_time = ?,
                   valid_end_time = ?, product_images = ?, rights_config = ?, status = 0, sort_no = ?,
                   audit_status = 1, audit_remark = null, audit_time = null, submit_time = now()
               where deleted = 0 and id = ?
               """,
            request.merchantId(),
            request.productType(),
            request.productName(),
@@ -914,45 +1085,48 @@
            toJson(request.productImages()),
            toJson(request.rightsConfig()),
            request.sortNo() == null ? 0 : request.sortNo(),
            id);
            productId);
      ensureUpdated(updated);
      appendProductAuditRecord(id);
      return product(id);
      appendProductAuditRecord(productId);
      return product(productId);
   }
   @Transactional
   public Map<String, Object> updateProductStatus(Long id, Byte status) {
      Number auditStatus = (Number) product(id).get("auditStatus");
      Long productId = resolveProductId(id);
      Number auditStatus = (Number) product(productId).get("auditStatus");
      if (auditStatus == null || auditStatus.intValue() != 2) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "Only approved products can be listed or unlisted");
         throw new BusinessException(ErrorCode.BAD_REQUEST, "只有审核通过的团单才能上架或下架");
      }
      int updated = jdbcTemplate.update("update merchant_product set status = ? where deleted = 0 and id = ?", status, id);
      int updated = jdbcTemplate.update("update merchant_product set status = ? where deleted = 0 and id = ?", status, productId);
      ensureUpdated(updated);
      return product(id);
      return product(productId);
   }
   @Transactional
   public Map<String, Object> deleteProduct(Long id) {
      Map<String, Object> existing = product(id);
      int updated = jdbcTemplate.update("update merchant_product set deleted = 1 where deleted = 0 and id = ?", id);
      Long productId = resolveProductId(id);
      Map<String, Object> existing = product(productId);
      int updated = jdbcTemplate.update("update merchant_product set deleted = 1 where deleted = 0 and id = ?", productId);
      ensureUpdated(updated);
      return existing;
   }
   @Transactional
   public Map<String, Object> auditProduct(Long id, ProductAuditRequest request) {
      Long productId = resolveProductId(id);
      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));
            """, productId));
      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");
      }
      Number currentAuditStatus = (Number) product(id).get("auditStatus");
      Number currentAuditStatus = (Number) product(productId).get("auditStatus");
      if (currentAuditStatus == null || currentAuditStatus.intValue() != 1) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "Product is not pending audit");
      }
@@ -960,9 +1134,9 @@
            update merchant_product
            set audit_status = ?, audit_remark = ?, audit_time = now(), status = ?
            where deleted = 0 and id = ?
            """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, id);
            """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, productId);
      ensureUpdated(updated);
      return product(id);
      return product(productId);
   }
   @Transactional
@@ -1236,6 +1410,35 @@
      }
   }
   /**
    * 团单在后台有两个 id 口径:商家编辑页的团单列表给的是商品 id,团单审核列表给的是审核记录 id。
    * 两个都认——否则拿另一口径的 id 去改团单,就只会在 update 影响 0 行之后抛出英文的
    * {@code Data not found},现场完全看不出是"id 口径不一致"。
    */
   private Long resolveProductId(Long id) {
      if (id == null) {
         throw new BusinessException(ErrorCode.BAD_REQUEST, "团单参数不能为空");
      }
      List<Long> direct = jdbcTemplate.queryForList(
            "select id from merchant_product where deleted = 0 and id = ?",
            Long.class,
            id);
      if (!direct.isEmpty()) {
         return direct.get(0);
      }
      List<Long> byAuditRecord = jdbcTemplate.queryForList("""
            select p.id
            from merchant_product_audit_record r
            join merchant_product p on p.id = r.product_id and p.deleted = 0
            where r.deleted = 0 and r.id = ?
            limit 1
            """, Long.class, id);
      if (!byAuditRecord.isEmpty()) {
         return byAuditRecord.get(0);
      }
      throw new BusinessException(ErrorCode.NOT_FOUND, "团单不存在或已被删除,请刷新列表后重试");
   }
   private void ensureMerchantExists(Long merchantId) {
      Long count = jdbcTemplate.queryForObject(
            "select count(*) from merchant where deleted = 0 and id = ?",
@@ -1418,6 +1621,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",
@@ -1427,13 +1642,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) {