| | |
| | | |
| | | 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.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; |
| | |
| | | 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; |
| | | } |
| | |
| | | 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 |
| | |
| | | countSql.append(" and date(publish_time) <= ?"); |
| | | args.add(publishEnd); |
| | | } |
| | | sql.append(" order by 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()); |
| | | } |
| | | |
| | | @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 = ? |
| | |
| | | } |
| | | |
| | | @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, |
| | |
| | | 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, |
| | |
| | | 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); |
| | | } |
| | |
| | | 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 |
| | |
| | | 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()) { |
| | |
| | | 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 = ?"); |
| | |
| | | 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) |
| | | 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, 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 |
| | | 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 |
| | |
| | | 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) { |
| | | 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(), |
| | |
| | | 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, "商户登录账号只能包含英文字母和数字"); |
| | | } |
| | |
| | | return one("select * from merchant where id = ?", id); |
| | | } |
| | | |
| | | void validateMerchantAddress(String merchantType, String address) { |
| | | if (!"apartment".equals(MerchantTypeCatalog.canonicalCode(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 MerchantTypeCatalog.find(merchantType) |
| | | .map(MerchantType::supportsMerchantAppLogin) |
| | | .orElse(false); |
| | | } |
| | | |
| | | 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的一位小数"); |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | 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(""" |
| | |
| | | } |
| | | |
| | | 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(), |
| | |
| | | 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()) { |
| | |
| | | } |
| | | 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; } |
| | | } |
| | | |
| | | /** 详情页轮播图按商家配置的服务类型:其余类型顶部图仍用封面图或团单封面。 */ |
| | | // 只有子女托管走通用的 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) { |
| | | 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 |
| | |
| | | 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, |
| | |
| | | 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 |
| | | 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 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 |
| | | 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) { |
| | | 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()) { |
| | |
| | | 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()); |
| | | 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(""" |
| | |
| | | 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()); |
| | | validateProductRights(request.rightsConfig()); |
| | | 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(), |
| | |
| | | toJson(request.productImages()), |
| | | toJson(request.rightsConfig()), |
| | | request.sortNo() == null ? 0 : request.sortNo(), |
| | | id); |
| | | productId); |
| | | ensureUpdated(updated); |
| | | 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 |
| | | """, 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"); |
| | | } |
| | |
| | | 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 |
| | | 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) |
| | |
| | | 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()); |
| | | } |
| | | |
| | |
| | | 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"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 团单在后台有两个 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) { |
| | |
| | | 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) { |
| | |
| | | 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) { |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | 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", |
| | |
| | | |
| | | 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) { |