shinnosuke
2026-09-18 c2f0ec3b3891bc177bdc58c2a111ab9c4fea911d
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantManagementService.java
@@ -9,6 +9,7 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
@@ -20,7 +21,11 @@
@Service
public class MerchantManagementService {
    private static final List<String> NOTICE_FIELDS = List.of("validity", "availableTime", "usage", "otherRules");
    /**
     * 购买须知里要落库的字段。{@code validPeriod} 是体检医院在后台团单里叫「可预约时间」的那一项,
     * 后台保存时写在同一个 purchase_notice 里,商家端要能读写同一个键,两端才不会各存一份。
     */
    private static final List<String> NOTICE_FIELDS = List.of("validity", "availableTime", "validPeriod", "usage", "otherRules");
    private final JdbcTemplate jdbc;
@@ -39,7 +44,10 @@
        result.put("storeName", store.get("storeName"));
        result.put("address", value(store.get("address"), value(merchant.get("address"), "")));
        result.put("coverUrl", value(merchant.get("coverUrl"), ""));
        result.put("businessStatus", businessStatus(merchant.get("businessStatus")));
        result.put("businessStatus", isBusinessOpen(merchant, principal.storeId()) ? "open" : "closed");
        // 商家端要按商家类型决定商品表单显示哪些字段(体检医院只配可预约时间 + 检查项目),
        // 类型跟着资料一起返回,页面不用再打一次接口。
        result.put("merchantTypeCode", value(merchant.get("merchantTypeCode"), ""));
        result.put("updatedAt", merchant.get("updatedAt"));
        Long productCount = jdbc.queryForObject("select count(*) from merchant_product where merchant_id = ? and deleted = 0 and status = 1 and audit_status = 2", Long.class, principal.merchantId());
        result.put("onlineProductCount", productCount == null ? 0 : productCount);
@@ -52,7 +60,9 @@
        merchant(principal.merchantId());
        store(principal);
        jdbc.update("update merchant set business_status = ? where id = ? and deleted = 0", status.toUpperCase(), principal.merchantId());
        return Map.of("businessStatus", status, "updatedAt", java.time.LocalDateTime.now().toString());
        return Map.of(
                "businessStatus", isBusinessOpen(merchant(principal.merchantId()), principal.storeId()) ? "open" : "closed",
                "updatedAt", java.time.LocalDateTime.now().toString());
    }
    @Transactional(readOnly = true)
@@ -113,7 +123,9 @@
                from merchant_product where id = ? and merchant_id = ? and deleted = 0
                """, productId, principal.merchantId());
        if (records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商品不存在");
        return toProductDetail(records.get(0));
        Map<String, Object> detail = toProductDetail(records.get(0));
        detail.put("merchantTypeCode", value(merchant(principal.merchantId()).get("merchantTypeCode"), ""));
        return detail;
    }
    @Transactional
@@ -122,6 +134,15 @@
        product(principal, productId);
        String coverUrl = request.coverUrl() == null ? "" : request.coverUrl().trim();
        if (coverUrl.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传商品封面图");
        // 体检医院在后台团单里没有「商品金额」这一项(后台保存时落 0,人才端据此显示「免费」),
        // 所以这里按商家类型区分:非体检医院仍然必须有大于 0 的金额。
        BigDecimal salePrice = request.salePrice();
        if (salePrice == null || salePrice.signum() <= 0) {
            if (!"medical".equals(value(merchant(principal.merchantId()).get("merchantTypeCode"), ""))) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "商品金额必须大于0");
            }
            salePrice = BigDecimal.ZERO;
        }
        String images = jsonArray(request.productImages() == null ? List.of() : request.productImages());
        Map<String, String> notice = new LinkedHashMap<>();
        for (String field : NOTICE_FIELDS) notice.put(field, request.purchaseNotice() == null ? "" : value(request.purchaseNotice().get(field), ""));
@@ -130,9 +151,34 @@
                set product_name = ?, sale_price = ?, cover_url = ?, product_images = ?, detail_text = ?, purchase_notice = ?,
                    audit_status = 1, status = 0, submit_time = now(), audit_remark = null
                where id = ? and merchant_id = ? and deleted = 0
                """, request.productName().trim(), request.salePrice(), coverUrl, images,
                """, request.productName().trim(), salePrice, coverUrl, images,
                value(request.detailText(), ""), jsonObject(notice), productId, principal.merchantId());
        appendProductAuditRecord(productId);
        return product(principal, productId);
    }
    private void appendProductAuditRecord(Long productId) {
        jdbc.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);
        jdbc.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
@@ -152,6 +198,8 @@
    @Transactional(readOnly = true)
    public List<Map<String, Object>> benefits(MerchantPrincipal principal) {
        store(principal);
        List<Map<String, Object>> configured = configuredBenefits(principal.merchantId());
        if (!configured.isEmpty()) return configured;
        return rows("""
                select card_level, card_name, benefit_text, theme
                from merchant_card_benefit
@@ -165,9 +213,95 @@
        )).toList();
    }
    private List<Map<String, Object>> configuredBenefits(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_rights_config from merchant where id = ? and deleted = 0 limit 1", merchantId);
        if (merchants.isEmpty()) return List.of();
        Object raw = merchants.get(0).get("merchantRightsConfig");
        if (raw == null || value(raw, "").isBlank()) return List.of();
        List<Map<String, Object>> rights = new ArrayList<>();
        try {
            if (raw instanceof List<?> list) {
                for (Object item : list) if (item instanceof Map<?, ?> map) rights.add(copyMap(map));
            } else {
                for (Object item : JSON.parseArray(value(raw, ""), Map.class)) if (item instanceof Map<?, ?> map) rights.add(copyMap(map));
            }
        } catch (Exception ignored) {
            return List.of();
        }
        Map<String, Map<String, Object>> byLevel = new LinkedHashMap<>();
        for (Map<String, Object> right : rights) {
            if (!booleanValue(right.get("enabled"))) continue;
            String level = normalizeLevel(value(right.get("level"), ""));
            if (!List.of("EXCELLENT", "OUTSTANDING", "ELITE").contains(level)) continue;
            byLevel.putIfAbsent(level, right);
        }
        return List.of("EXCELLENT", "OUTSTANDING", "ELITE").stream()
                .filter(byLevel::containsKey).map(level -> toBenefit(byLevel.get(level)))
                .filter(item -> !value(item.get("benefitText"), "").isBlank()).toList();
    }
    private Map<String, Object> toBenefit(Map<String, Object> right) {
        String level = value(right.get("level"), "");
        String normalized = normalizeLevel(level);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("cardLevel", normalized);
        result.put("cardName", value(right.get("cardName"), cardName(normalized, level)));
        String text = value(right.get("detail"), "");
        if (text.isBlank()) text = value(right.get("consumeDesc"), "");
        if (text.isBlank()) text = configuredConsumeText(right);
        result.put("benefitText", text);
        result.put("theme", theme(normalized));
        return result;
    }
    private String configuredConsumeText(Map<String, Object> right) {
        String type = value(right.get("consumeType"), "");
        if ("优惠".equals(type) && !value(right.get("consumeCoupon"), "").isBlank()) return "到店消费立减" + right.get("consumeCoupon") + "元";
        String discount = "折扣".equals(type) ? value(right.get("consumeDiscount"), "") : value(right.get("discount"), "");
        return discount.isBlank() ? "" : "到店消费" + discount + "折";
    }
    private String normalizeLevel(String value) {
        String upper = value.toUpperCase();
        if (upper.contains("EXCELLENT") || value.contains("卓越")) return "EXCELLENT";
        if (upper.contains("OUTSTANDING") || value.contains("杰出")) return "OUTSTANDING";
        if (upper.contains("ELITE") || value.contains("菁英") || value.contains("精英")) return "ELITE";
        return upper;
    }
    private String cardName(String normalized, String fallback) {
        return switch (normalized) {
            case "EXCELLENT" -> "卓越人才卡";
            case "OUTSTANDING" -> "杰出人才卡";
            case "ELITE" -> "菁英人才卡";
            default -> fallback;
        };
    }
    private String theme(String normalized) {
        return switch (normalized) {
            case "OUTSTANDING" -> "outstanding";
            case "ELITE" -> "elite";
            default -> "excellent";
        };
    }
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
        String text = value(value, "");
        return "true".equalsIgnoreCase(text) || "1".equals(text) || "yes".equalsIgnoreCase(text);
    }
    private Map<String, Object> copyMap(Map<?, ?> source) {
        Map<String, Object> result = new LinkedHashMap<>();
        for (Map.Entry<?, ?> entry : source.entrySet()) result.put(String.valueOf(entry.getKey()), entry.getValue());
        return result;
    }
    private Map<String, Object> merchant(Long merchantId) {
        List<Map<String, Object>> merchants = rows("""
                select id, merchant_name, address, cover_url, business_status, updated_at
                select id, merchant_name, merchant_type_code, address, cover_url, business_status, status, updated_at
                from merchant where id = ? and deleted = 0
                """, merchantId);
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户不存在");
@@ -234,6 +368,30 @@
        return "closed".equals(status) || "休息中".equals(status) ? "closed" : "open";
    }
    private boolean isBusinessOpen(Map<String, Object> merchant, Long storeId) {
        if (!Long.valueOf(1).equals(number(merchant.get("status"))) || "closed".equals(businessStatus(merchant.get("businessStatus")))) {
            return false;
        }
        Long configuredPeriods = jdbc.queryForObject("""
                select count(*)
                from merchant_business_hours
                where merchant_id = ? and store_id = ?
                """, Long.class, merchant.get("id"), storeId);
        if (configuredPeriods == null || configuredPeriods == 0) {
            return true;
        }
        Long activePeriods = jdbc.queryForObject("""
                select count(*)
                from merchant_business_hours
                where merchant_id = ? and store_id = ?
                  and (
                    (start_time <= end_time and current_time() >= start_time and current_time() < end_time)
                    or (start_time > end_time and (current_time() >= start_time or current_time() < end_time))
                  )
                """, Long.class, merchant.get("id"), storeId);
        return activePeriods != null && activePeriods > 0;
    }
    private String auditStatus(Long status) {
        if (status != null && status == 2) return "approved";
        if (status != null && status == 3) return "rejected";