package com.talentcard.server.business.portal; import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.TypeReference; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.PageResult; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class ParkingMerchantPortalService { private final JdbcTemplate jdbc; public ParkingMerchantPortalService(JdbcTemplate jdbc) { this.jdbc = jdbc; } @Transactional(readOnly = true) public Map detail(Long merchantId, Long talentCardId) { Map merchant = parkingMerchant(merchantId); Map benefit = parkingBenefit(merchantId); String cardLevel = talentCardId == null ? "" : talentCardLevel(talentCardId); List> rights = parseList(merchant.get("merchantRightsConfig")); Map legacyRight = matchingRight(rights, cardLevel); Map configuredRight = parkingCardLevelRight(merchantId, cardLevel); Map right = mergeRight(legacyRight, configuredRight); boolean hasCardRightsConfig = (!rights.isEmpty() || configuredRight != null) && !cardLevel.isBlank(); boolean available = number(merchant.get("status")) == 1 && (!hasCardRightsConfig || right != null) && (right == null || (booleanValue(right.get("enabled")) && rightStatusEnabled(right))); Map result = new LinkedHashMap<>(); result.put("merchantId", merchant.get("id")); result.put("merchantName", merchant.get("merchantName")); result.put("merchantType", "parking"); result.put("coverUrl", merchant.get("coverUrl")); result.put("address", merchant.get("address")); result.put("contactMobile", merchant.get("contactMobile")); result.put("latitude", merchant.get("latitude")); result.put("longitude", merchant.get("longitude")); result.put("available", available); result.put("unavailableReason", available ? "" : unavailableReason(merchant, right)); // 游客没有人才卡等级,不具备可展示的权益标签,避免把默认权益当成该用户的标签。 boolean hasCardLevel = !cardLevel.isBlank(); List benefitTags = hasCardLevel ? parkingBenefitTags(merchantId, right) : List.of(); String displayTag = benefitTags.isEmpty() ? "" : benefitTags.get(0); result.put("serviceLabel", displayTag); result.put("discountText", displayTag); result.put("benefitTags", benefitTags); result.put("benefitText", benefitText(merchant, right, benefit)); result.put("applicableCards", hasCardLevel ? applicableCards(right, benefit) : ""); result.put("usageInstructions", usageInstructions(merchant, right)); result.put("rightsText", rightsText(merchant, right)); String parkingType = text(right == null ? null : right.get("parkingType"), "paid"); // 付费权益到店出示人才码,免费权益先登记车牌。 result.put("actionMode", "paid".equalsIgnoreCase(parkingType) ? "talentCode" : "plate"); // 车牌表单模式以商家权益里配的「停车场选择范围」为准: // 全部可选(all)只要填车牌;只选其一(selected)才需要挑停车场。 String plateFormMode = effectivePlateFormMode(right, benefit); result.put("plateFormMode", plateFormMode); result.put("benefit", benefitView(benefit, plateFormMode)); result.put("applicableStores", parkingStores(merchant.get("usageInstructions"))); result.put("merchantRightsConfig", rights); result.put("merchantRight", right == null ? Map.of() : right); result.put("parkingType", parkingType); result.put("parkingScope", text(right == null ? null : right.get("parkingScope"), "all")); result.put("consumeDesc", text(right == null ? null : right.get("consumeDesc"), "")); return result; } @Transactional(readOnly = true) public PageResult> parkingLots(Long merchantId, String keyword, int page, int size) { Map merchant = parkingMerchant(merchantId); List> stores = parkingStores(merchant.get("usageInstructions")); String cleaned = keyword == null ? "" : keyword.trim(); List> filtered = stores.stream() .filter(item -> cleaned.isEmpty() || text(item.get("storeName"), "").contains(cleaned) || text(item.get("parkingLotName"), "").contains(cleaned) || text(item.get("name"), "").contains(cleaned) || text(item.get("address"), "").contains(cleaned)) .toList(); int safePage = Math.max(1, page); int safeSize = Math.min(50, Math.max(1, size)); int from = Math.min(filtered.size(), (safePage - 1) * safeSize); int to = Math.min(filtered.size(), from + safeSize); return new PageResult<>(filtered.subList(from, to), safePage, safeSize, filtered.size()); } @Transactional public Map registerPlate(Long merchantId, ParkingPlateRegistrationRequest request, String idempotencyKey) { if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128) { throw new BusinessException(ErrorCode.BAD_REQUEST, "提交凭证不能为空"); } List> existing = query(""" select id, registration_no, status from transport_parking_plate_registration where idempotency_key = ? and deleted = 0 """, idempotencyKey); if (!existing.isEmpty()) return registrationView(existing.get(0)); Map merchant = parkingMerchant(merchantId); if (number(merchant.get("status")) != 1) throw new BusinessException(ErrorCode.CONFLICT, "停车服务当前不可用"); Map benefit = one(""" select id, action_mode, plate_form_mode, status from transport_parking_benefit where id = ? and merchant_id = ? and deleted = 0 """, request.benefitId(), merchantId); if (number(benefit.get("status")) != 1) throw new BusinessException(ErrorCode.CONFLICT, "停车权益当前不可用"); Map card = activeTalentCard(request.talentId()); String cardLevel = text(card.get("cardLevel"), ""); Map right = mergeRight( matchingRight(parseList(merchant.get("merchantRightsConfig")), cardLevel), parkingCardLevelRight(merchantId, cardLevel)); if (right == null || !booleanValue(right.get("enabled")) || !rightStatusEnabled(right)) { throw new BusinessException(ErrorCode.CONFLICT, "当前人才卡暂无可用停车权益"); } if (!"free".equalsIgnoreCase(text(right.get("parkingType"), "paid"))) { throw new BusinessException(ErrorCode.CONFLICT, "付费停车权益无需登记车牌"); } Map selectedParkingLot = null; if (requiresParkingLotSelection(right, benefit)) { if (request.parkingLotId() == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择停车场"); selectedParkingLot = parkingStores(merchant.get("usageInstructions")).stream() .filter(item -> String.valueOf(request.parkingLotId()).equals(String.valueOf(item.get("id")))) .findFirst() .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "停车场不可用")); } Integer duplicateCount = jdbc.queryForObject(""" select count(*) from transport_parking_plate_registration where merchant_id = ? and talent_id = ? and plate_number = ? and deleted = 0 and status in ('SUBMITTED', 'ACTIVE') """, Integer.class, merchantId, request.talentId(), request.plateNumber()); if (duplicateCount != null && duplicateCount > 0) throw new BusinessException(ErrorCode.CONFLICT, "该车牌已登记,如需变更请联系停车服务商"); String registrationNo = BusinessIdGenerator.next("PR"); jdbc.update(""" insert into transport_parking_plate_registration( registration_no, merchant_id, benefit_id, parking_lot_id, parking_lot_name_snapshot, parking_lot_address_snapshot, talent_id, plate_number, status, idempotency_key ) values (?, ?, ?, ?, ?, ?, ?, ?, 'SUBMITTED', ?) """, registrationNo, merchantId, request.benefitId(), request.parkingLotId(), selectedParkingLot == null ? null : parkingLotName(selectedParkingLot), selectedParkingLot == null ? null : text(selectedParkingLot.get("address"), ""), request.talentId(), request.plateNumber(), idempotencyKey); return registrationView(one(""" select id, registration_no, status from transport_parking_plate_registration where idempotency_key = ? and deleted = 0 """, idempotencyKey)); } private Map parkingMerchant(Long merchantId) { return one(""" select id, merchant_name, contact_mobile, address, cover_url, service_label, usage_instructions, rights_text, merchant_rights_config, status, latitude, longitude from merchant where id = ? and merchant_type_code = 'parking' and deleted = 0 """, merchantId); } private Map parkingBenefit(Long merchantId) { List> rows = query(""" select id, talent_levels, benefit_text, action_mode, plate_form_mode, valid_end, unavailable_reason, status from transport_parking_benefit where merchant_id = ? and deleted = 0 order by id asc limit 1 """, merchantId); return rows.isEmpty() ? new LinkedHashMap<>() : rows.get(0); } private Map parkingCardLevelRight(Long merchantId, String cardLevel) { if (cardLevel == null || cardLevel.isBlank()) return null; List> rows = query(""" select card_level as level, enabled, label as tag, benefit_detail as detail, charge_type as chargeType, selection_mode as selectionMode, store_consume_enabled as storeConsumeEnabled, discount_text as discountText, government_subsidy as governmentSubsidy, consume_usage_text as consumeDesc, status as rightStatus from transport_parking_card_level_right where merchant_id = ? and card_level = ? and deleted = 0 limit 1 """, merchantId, cardLevel); if (rows.isEmpty()) return null; Map right = rows.get(0); String chargeType = text(right.get("chargeType"), "FREE"); right.put("parkingType", "PAID".equalsIgnoreCase(chargeType) ? "paid" : "free"); String selectionMode = text(right.get("selectionMode"), "ALL"); right.put("parkingScope", "ONE".equalsIgnoreCase(selectionMode) ? "selected" : "all"); // 付费权益的到店消费使用描述就是详情页的使用说明。 if ("paid".equals(right.get("parkingType")) && text(right.get("consumeDesc"), "").isBlank()) { right.put("consumeDesc", text(right.get("detail"), "")); } return right; } private Map mergeRight(Map legacyRight, Map configuredRight) { if (legacyRight == null && configuredRight == null) return null; Map result = new LinkedHashMap<>(); if (legacyRight != null) result.putAll(legacyRight); if (configuredRight != null) { for (Map.Entry entry : configuredRight.entrySet()) { Object value = entry.getValue(); if (value != null && (!(value instanceof String text) || !text.isBlank())) result.put(entry.getKey(), value); } } String parkingType = text(result.get("parkingType"), "free"); if ("paid".equalsIgnoreCase(parkingType) && !text(result.get("consumeDesc"), "").isBlank()) { result.put("detail", text(result.get("consumeDesc"), "")); } return result; } private boolean rightStatusEnabled(Map right) { Object status = right.get("rightStatus"); return status == null || number(status) != 0; } private String usageInstructions(Map merchant, Map right) { if (right != null && "paid".equalsIgnoreCase(text(right.get("parkingType"), ""))) { String consumeDesc = text(right.get("consumeDesc"), text(right.get("detail"), "")); if (!consumeDesc.isBlank()) return consumeDesc; } return text(merchant.get("usageInstructions"), "请咨询商户"); } private List> parkingStores(Object usageInstructions) { List> stores = parseList(usageInstructions); int index = 0; for (Map store : stores) { Object id = store.get("id"); if (id == null) store.put("id", index + 1); Object name = store.get("name"); if (name != null && store.get("storeName") == null) store.put("storeName", name); if (name != null && store.get("parkingLotName") == null) store.put("parkingLotName", name); store.put("canNavigate", hasCoordinate(store)); index++; } return stores; } private String parkingLotName(Map parkingLot) { return text(parkingLot.get("parkingLotName"), text(parkingLot.get("storeName"), text(parkingLot.get("name"), ""))); } private Map benefitView(Map benefit, String plateFormMode) { Map result = new LinkedHashMap<>(); result.put("benefitId", benefit.get("id")); result.put("actionMode", text(benefit.get("actionMode"), "plate")); result.put("plateFormMode", plateFormMode); result.put("validEnd", benefit.get("validEnd")); return result; } /** * 是否需要小程序里挑一个停车场。 * 以商家权益(merchant_rights_config)里的 parkingScope 为准:selected/ONE=只选其一,all=全部可选; * 权益里没配范围的旧数据,继续沿用停车权益表的 plate_form_mode。 */ private boolean requiresParkingLotSelection(Map right, Map benefit) { if (right != null) { String scope = text(right.get("parkingScope"), "").trim().toUpperCase(Locale.ROOT); if (!scope.isBlank()) { return "SELECTED".equals(scope) || "ONE".equals(scope) || scope.contains("只选") || scope.contains("其一"); } } return "withParking".equalsIgnoreCase(text(benefit.get("plateFormMode"), "")); } private String effectivePlateFormMode(Map right, Map benefit) { return requiresParkingLotSelection(right, benefit) ? "withParking" : "simple"; } private Map registrationView(Map row) { Map result = new LinkedHashMap<>(); result.put("registrationId", row.get("id")); result.put("registrationNo", row.get("registrationNo")); result.put("status", row.get("status")); return result; } private Map matchingRight(List> rights, String cardLevel) { if (rights.isEmpty()) return null; String normalizedCardLevel = normalizedCardLevel(cardLevel); if (normalizedCardLevel.isBlank()) { return rights.stream().filter(item -> booleanValue(item.get("enabled"))).findFirst().orElse(null); } for (Map right : rights) { if (!booleanValue(right.get("enabled"))) continue; if (normalizedCardLevel.equals(normalizedCardLevel(text(right.get("level"), "")))) return right; } return null; } private String applicableCards(Map right, Map benefit) { return text(right == null ? null : right.get("tag"), ""); } private String rightsText(Map merchant, Map right) { if (right != null) { String detail = text(right.get("detail"), ""); if (!detail.isBlank()) return detail; String consumeDesc = text(right.get("consumeDesc"), ""); if (!consumeDesc.isBlank()) return consumeDesc; String tag = text(right.get("tag"), ""); if (!tag.isBlank()) return tag; } return text(merchant.get("rightsText"), "请咨询商户"); } private String benefitText(Map merchant, Map right, Map benefit) { if (right != null) { String detail = text(right.get("detail"), ""); if (!detail.isBlank()) return detail; String tag = text(right.get("tag"), ""); if (!tag.isBlank()) return tag; } return text(benefit.get("benefitText"), text(merchant.get("rightsText"), "免费停车")); } private List parkingBenefitTags(Long merchantId, Map right) { if (right == null || !booleanValue(right.get("enabled")) || !rightStatusEnabled(right)) return List.of(); String parkingType = text(right.get("parkingType"), "free"); if ("free".equalsIgnoreCase(parkingType)) return List.of("免费"); List tags = new ArrayList<>(); if (hasActiveMerchantCoupon(merchantId)) tags.add("券"); boolean hasStoredConsumeSetting = right.containsKey("storeConsumeEnabled"); boolean storeConsume = hasStoredConsumeSetting ? booleanValue(right.get("storeConsumeEnabled")) : booleanValue(right.get("storeConsume")); if (storeConsume) { String rawDiscount = text(right.get("discount"), text(right.get("discountText"), "")) .replace("折", "").trim(); try { BigDecimal discount = new BigDecimal(rawDiscount).stripTrailingZeros(); if (discount.compareTo(BigDecimal.ZERO) > 0 && discount.compareTo(BigDecimal.TEN) <= 0) { tags.add(discount.toPlainString() + "折"); } } catch (NumberFormatException ignored) { // Invalid or missing discounts do not produce a display tag. } } return tags; } private boolean hasActiveMerchantCoupon(Long merchantId) { Integer count = jdbc.queryForObject(""" select count(*) from merchant_coupon where merchant_id = ? and deleted = 0 and status = 1 and (valid_start is null or valid_start <= current_date) and (valid_end is null or valid_end >= current_date) """, Integer.class, merchantId); return count != null && count > 0; } private String unavailableReason(Map merchant, Map right) { if (number(merchant.get("status")) != 1) return "停车服务当前不可用"; if (right == null) return "当前人才卡暂无可用停车权益"; return "停车权益当前不可用"; } private List> parseList(Object value) { if (value instanceof List list) { return list.stream().filter(Map.class::isInstance).map(item -> copyMap((Map) item)).toList(); } String text = value == null ? "" : String.valueOf(value).trim(); if (text.isBlank()) return List.of(); try { List> rows = JSON.parseObject(text, new TypeReference>>() {}); return rows == null ? List.of() : rows.stream().map(this::copyMap).toList(); } catch (Exception ignored) { return List.of(); } } private Map copyMap(Map source) { Map result = new LinkedHashMap<>(); for (Map.Entry entry : source.entrySet()) result.put(String.valueOf(entry.getKey()), entry.getValue()); return result; } private String talentCardLevel(Long talentCardId) { List> rows = query("select card_level from talent_card where id = ? and deleted = 0 limit 1", talentCardId); return rows.isEmpty() ? "" : text(rows.get(0).get("cardLevel"), ""); } private Map activeTalentCard(Long talentId) { List> rows = query(""" select id, card_level from talent_card where talent_id = ? and card_type = 'PERSONAL' and status = 1 and deleted = 0 order by id desc limit 1 """, talentId); if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "人才用户或有效人才卡不存在"); return rows.get(0); } private String normalizedCardLevel(String value) { String raw = value == null ? "" : value.trim(); String upper = raw.toUpperCase(Locale.ROOT); if (upper.contains("EXCELLENT") || raw.contains("卓越")) return "卓越人才卡"; if (upper.contains("OUTSTANDING") || raw.contains("杰出")) return "杰出人才卡"; if (upper.contains("ELITE") || raw.contains("菁英") || raw.contains("精英")) return "菁英人才卡"; return raw; } private boolean hasCoordinate(Map store) { return decimal(store.get("latitude")) != 0D && decimal(store.get("longitude")) != 0D; } private double decimal(Object value) { if (value instanceof Number number) return number.doubleValue(); try { return value == null ? 0D : Double.parseDouble(String.valueOf(value)); } catch (Exception ignored) { return 0D; } } private boolean booleanValue(Object value) { if (value instanceof Boolean bool) return bool; if (value instanceof Number number) return number.intValue() != 0; String text = value == null ? "" : String.valueOf(value).trim(); return "true".equalsIgnoreCase(text) || "1".equals(text) || "yes".equalsIgnoreCase(text); } private List> query(String sql, Object... args) { return jdbc.queryForList(sql, args).stream().map(DatabaseRows::normalize).toList(); } private Map one(String sql, Object... args) { List> rows = query(sql, args); if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "停车权益配置不存在"); return rows.get(0); } private int number(Object value) { return value instanceof Number number ? number.intValue() : 0; } private String text(Object value, String fallback) { if (value == null) return fallback; String text = String.valueOf(value).trim(); return text.isEmpty() ? fallback : text; } }