shinnosuke
2026-08-21 bc2efbdb52190fe2e7be9920bbed6902d4ee28aa
fix: centralize merchant type integration
4个文件已添加
28个文件已修改
788 ■■■■■ 已修改文件
rencaika/pages/family-card-apply/family-card-apply.uvue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashCredentialMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingPlateRegistrationMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyAdminService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyPortalService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V118__resync_premium_topic_merchant_mappings.sql 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V119__add_canonical_merchant_type_code.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/family-card-apply/family-card-apply.uvue
@@ -15,7 +15,7 @@
                <view class="input-box"><input v-model="form.name" maxlength="20" placeholder="请输入姓名" placeholder-class="input-placeholder" /></view>
                <text class="field-label">身份证号</text>
                <view class="input-box"><input :value="form.idCard" maxlength="18" placeholder="请输入身份证号" placeholder-class="input-placeholder" @input="handleIdCardInput" /></view>
                <view class="input-box"><input :value="form.idCard" type="idcard" maxlength="18" placeholder="请输入身份证号" placeholder-class="input-placeholder" @input="handleIdCardInput" /></view>
                <text class="field-label">手机号</text>
                <view class="input-box"><input v-model="form.phone" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="input-placeholder" /></view>
@@ -52,6 +52,14 @@
    const talentCardId = ref('')
    const applicationId = ref('')
    const auditRemark = ref('')
    const normalizeIdCard = (value: string): string => {
        const raw = value.toUpperCase().replace(/[^0-9X]/g, '')
        const xIndex = raw.indexOf('X')
        if (xIndex >= 17) {
            return raw.substring(0, xIndex).replace(/[^0-9]/g, '').slice(0, 17) + 'X'
        }
        return raw.replace(/[^0-9]/g, '').slice(0, 18)
    }
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -81,7 +89,7 @@
            if (data == null) return
            const next = form.value
            next.name = data.dependentName != null ? data.dependentName : ''
            next.idCard = data.dependentIdCardNo != null ? data.dependentIdCardNo : ''
            next.idCard = data.dependentIdCardNo != null ? normalizeIdCard('' + data.dependentIdCardNo) : ''
            next.phone = data.dependentMobile != null ? data.dependentMobile : ''
            next.relation = data.relation != null ? data.relation : ''
            form.value = next
@@ -98,11 +106,7 @@
        form.value = next
    }
    const handleIdCardInput = (event: any) => {
        const raw = (event.detail.value as string).toUpperCase()
        let normalized = raw.replace(/[^0-9X]/g, '').slice(0, 18)
        if (normalized.indexOf('X') >= 0 && (normalized.length != 18 || !normalized.endsWith('X'))) {
            normalized = normalized.replace(/X/g, '')
        }
        const normalized = normalizeIdCard(event.detail.value as string)
        const next = form.value
        next.idCard = normalized
        form.value = next
talent-card-admin/src/views/MerchantCreateView.vue
@@ -109,6 +109,7 @@
const supportsMerchantAppLogin = computed(() => (
  isServiceMerchant.value
  || isApartmentMerchant.value
  || isHotelMerchant.value
  || isCarWashMerchant.value
  || isHealthCheckMerchant.value
  || isParkingMerchant.value
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -17,6 +17,8 @@
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.MerchantTypeCatalog;
import com.talentcard.server.business.merchant.MerchantTypeCatalog.MerchantType;
import com.talentcard.server.business.merchant.auth.MerchantPasswordHasher;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -28,12 +30,6 @@
@Service
public class AdminBusinessService {
    private static final Set<String> MERCHANT_APP_LOGIN_TYPES = Set.of(
            "餐饮服务", "景区服务", "免费体检", "配偶推荐就业", "金融服务", "行政审批", "子女托管",
            "酒店服务", "公寓租赁", "加油服务", "停车服务", "自助洗车", "购房优惠", "购车服务",
            "家电服务", "数码服务", "文化服务", "电信服务", "口腔治疗保健"
    );
    private final JdbcTemplate jdbcTemplate;
@@ -255,9 +251,9 @@
            args.add("%" + contactMobile.trim() + "%");
        }
        if (type != null && !type.isBlank()) {
            sql.append(" and m.merchant_type = ?");
            countSql.append(" and m.merchant_type = ?");
            args.add(type);
            sql.append(" and m.merchant_type_code = ?");
            countSql.append(" and m.merchant_type_code = ?");
            args.add(MerchantTypeCatalog.canonicalCode(type));
        }
        if (status != null) {
            sql.append(" and m.status = ?");
@@ -362,16 +358,18 @@
    @Transactional
    public Map<String, Object> createMerchant(MerchantCreateRequest request) {
        validateMerchantAddress(request.merchantType(), request.address());
        MerchantType merchantType = requireMerchantType(request.merchantType());
        validateMerchantAddress(merchantType.code(), request.address());
        validateRequiredMerchantCreateFields(request);
        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(),
@@ -386,11 +384,12 @@
                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, request.merchantType());
        ensureFuelBenefit(id, request.merchantType(), request.serviceLabel());
        ensureParkingBenefit(id, merchantType.code());
        ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
        syncPremiumTopicMerchant(id, merchantType, request.sortNo());
        Long storeId = createDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
        if (hasMerchantAppAccess(request)) {
@@ -412,7 +411,7 @@
    }
    void validateMerchantAddress(String merchantType, String address) {
        if (!"公寓租赁".equals(merchantType) && (address == null || address.isBlank())) {
        if (!"apartment".equals(MerchantTypeCatalog.canonicalCode(merchantType)) && (address == null || address.isBlank())) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入商家地址");
        }
    }
@@ -423,11 +422,13 @@
    }
    boolean supportsMerchantAppLogin(String merchantType) {
        return MERCHANT_APP_LOGIN_TYPES.contains(merchantType);
        return MerchantTypeCatalog.find(merchantType)
                .map(MerchantType::supportsMerchantAppLogin)
                .orElse(false);
    }
    private void validateRequiredMerchantCreateFields(MerchantCreateRequest request) {
        if (!Set.of("景区服务", "餐饮服务").contains(request.merchantType())) {
        if (!Set.of("scenic", "food").contains(MerchantTypeCatalog.canonicalCode(request.merchantType()))) {
            return;
        }
        if (request.loginAccount() == null || !request.loginAccount().trim().matches("^[A-Za-z0-9]{1,32}$")) {
@@ -476,21 +477,24 @@
    }
    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());
        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(),
@@ -510,8 +514,9 @@
                request.status() == null ? 1 : request.status(),
                id);
        ensureUpdated(updated);
        ensureParkingBenefit(id, request.merchantType());
        ensureFuelBenefit(id, request.merchantType(), request.serviceLabel());
        ensureParkingBenefit(id, merchantType.code());
        ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
        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()) {
@@ -549,7 +554,7 @@
    }
    private void ensureParkingBenefit(Long merchantId, String merchantType) {
        if (!"停车服务".equals(merchantType) && !"parking".equalsIgnoreCase(merchantType)) return;
        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',
@@ -560,7 +565,7 @@
    }
    private void ensureFuelBenefit(Long merchantId, String merchantType, String serviceLabel) {
        if (!"加油服务".equals(merchantType) && !"fuel".equalsIgnoreCase(merchantType)) return;
        if (!"fuel".equals(merchantType)) return;
        String discountText = serviceLabel == null || serviceLabel.isBlank() ? "请咨询商户" : serviceLabel.trim();
        jdbcTemplate.update("""
                insert into transport_fuel_benefit(
@@ -572,6 +577,35 @@
                       '具体适用油品、优惠额度、可用时段和活动规则以加油站说明为准。', 1, 0
                where not exists (select 1 from transport_fuel_benefit where merchant_id = ? and deleted = 0)
                """, merchantId, discountText, merchantId);
    }
    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
@@ -657,9 +691,9 @@
            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 = ?");
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java
New file
@@ -0,0 +1,155 @@
package com.talentcard.server.business.merchant;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public final class MerchantTypeCatalog {
    private static final Map<String, MerchantType> BY_ALIAS;
    private static final List<MerchantType> TYPES;
    static {
        List<MerchantType> types = List.of(
                type("food", "餐饮服务", "暖心食享", "MERCHANT", null, null, null, true,
                        "餐饮", "餐饮商家"),
                type("scenic", "景区服务", "悦览花城", "MERCHANT", null, null, null, true,
                        "景区", "景点服务"),
                type("hotel", "酒店服务", "舒心安居", "MERCHANT", null, null, null, true,
                        "酒店", "住宿服务"),
                type("apartment", "公寓租赁", "舒心安居", "MERCHANT", null, null, null, true,
                        "公寓租赁服务", "公寓服务"),
                type("cinema", "观影服务", "怡乐生活", "CINEMA", null, null, null, true,
                        "theatre", "电影院", "影院", "影城", "剧院"),
                type("fuel", "加油服务", "畅行无忧", "FUEL", null, null, null, true,
                        "加油", "加油站"),
                type("parking", "停车服务", "畅行无忧", "PARKING", null, null, null, true,
                        "停车", "停车场"),
                type("carWash", "自助洗车", "畅行无忧", "CAR_WASH", null, null, null, true,
                        "自主洗车", "自助洗车服务", "洗车服务"),
                type("medical", "免费体检", "臻享礼遇", "MEDICAL", "medical", "MEDICAL", null, true,
                        "healthCheck", "体检服务", "医疗体检"),
                type("employment", "配偶推荐就业", "臻享礼遇", "SPOUSE_EMPLOYMENT", "employment", "SPOUSE_EMPLOYMENT", null, true,
                        "就业服务", "配偶就业"),
                type("finance", "金融服务", "臻享礼遇", "FINANCE", "finance", "FINANCE", null, true,
                        "金融"),
                type("approval", "行政审批", "臻享礼遇", "ADMIN_APPROVAL", "approval", "ADMIN_APPROVAL", null, true,
                        "行政审批服务", "政务服务"),
                type("children", "子女托管", "臻享礼遇", "CHILD_ENTRUSTMENT", "children", "CHILD_ENTRUSTMENT", null, true,
                        "学生托付", "学生托管", "子女托管服务"),
                type("housing", "购房优惠", "惠享好物", "SHOPPING", null, null, "housing", true,
                        "购房服务", "住房服务"),
                type("car", "购车服务", "惠享好物", "SHOPPING", null, null, "car", true,
                        "购车优惠", "汽车服务"),
                type("appliance", "家电服务", "惠享好物", "SHOPPING", null, null, "appliance", true,
                        "家用电器服务"),
                type("digital", "数码服务", "惠享好物", "SHOPPING", null, null, "digital", true,
                        "数码产品服务"),
                type("culture", "文化服务", "惠享好物", "SHOPPING", null, null, "book", true,
                        "图书服务", "文化图书服务"),
                type("telecom", "电信服务", "惠享好物", "SHOPPING", null, null, "digital", true,
                        "通信服务"),
                type("dental", "口腔治疗保健", "臻享礼遇", "MERCHANT", null, null, null, true,
                        "口腔服务", "口腔保健"),
                type("shopping", "购物服务", "惠享好物", "SHOPPING", null, null, null, true,
                        "惠享好物")
        );
        Map<String, MerchantType> aliases = new LinkedHashMap<>();
        for (MerchantType type : types) {
            register(aliases, type.code(), type);
            register(aliases, type.displayName(), type);
            for (String alias : type.aliases()) register(aliases, alias, type);
        }
        TYPES = List.copyOf(types);
        BY_ALIAS = Map.copyOf(aliases);
    }
    private MerchantTypeCatalog() {
    }
    public static Optional<MerchantType> find(String value) {
        return Optional.ofNullable(BY_ALIAS.get(normalize(value)));
    }
    public static String canonicalCode(String value) {
        return find(value).map(MerchantType::code).orElse("other");
    }
    public static String displayName(String value) {
        return find(value).map(MerchantType::displayName).orElseGet(() -> value == null ? "" : value.trim());
    }
    public static List<String> codesForTopic(String topicName) {
        return TYPES.stream()
                .filter(type -> type.topicName().equals(topicName))
                .map(MerchantType::code)
                .toList();
    }
    public static List<String> codesForRequestedValue(String value) {
        List<String> topicCodes = codesForTopic(value == null ? "" : value.trim());
        if (!topicCodes.isEmpty()) return topicCodes;
        return find(value).map(type -> List.of(type.code())).orElseGet(List::of);
    }
    public static List<String> codesForShoppingCategory(String categoryKey) {
        return TYPES.stream()
                .filter(type -> categoryKey != null && categoryKey.equals(type.shoppingCategoryKey()))
                .map(MerchantType::code)
                .toList();
    }
    public static Optional<MerchantType> forPremiumCategory(String categoryKey) {
        return TYPES.stream()
                .filter(type -> categoryKey != null && categoryKey.equals(type.premiumCategoryKey()))
                .findFirst();
    }
    public static Collection<MerchantType> all() {
        return TYPES;
    }
    private static MerchantType type(
            String code,
            String displayName,
            String topicName,
            String detailType,
            String premiumCategoryKey,
            String premiumBusinessType,
            String shoppingCategoryKey,
            boolean supportsMerchantAppLogin,
            String... aliases
    ) {
        Set<String> values = new LinkedHashSet<>();
        if (aliases != null) values.addAll(List.of(aliases));
        return new MerchantType(code, displayName, topicName, detailType, premiumCategoryKey,
                premiumBusinessType, shoppingCategoryKey, supportsMerchantAppLogin, Set.copyOf(values));
    }
    private static void register(Map<String, MerchantType> aliases, String alias, MerchantType type) {
        String key = normalize(alias);
        if (!key.isBlank()) aliases.put(key, type);
    }
    private static String normalize(String value) {
        return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
    }
    public record MerchantType(
            String code,
            String displayName,
            String topicName,
            String detailType,
            String premiumCategoryKey,
            String premiumBusinessType,
            String shoppingCategoryKey,
            boolean supportsMerchantAppLogin,
            Set<String> aliases
    ) {
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
@@ -71,9 +71,8 @@
        if (serviceId == null || serviceId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "行政审批服务信息不能为空");
        List<Map<String, Object>> rows = query("""
                select m.id
                from merchant m left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0
                where m.id = ? and m.deleted = 0
                  and ((mapping.category_key = 'approval') or (mapping.id is null and m.merchant_type = '行政审批'))
                from merchant m
                where m.id = ? and m.deleted = 0 and m.merchant_type_code = 'approval'
                """, serviceId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "行政审批服务不存在");
        return rows.get(0);
talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java
@@ -41,7 +41,7 @@
        Map<String, Object> merchant = one("""
                select id, merchant_name, contact_mobile, address, cover_url, service_label, rights_text, merchant_rights_config, status
                from merchant
                where id = ? and merchant_type in ('carWash', '自主洗车', '自助洗车', '自助洗车服务') and deleted = 0
                where id = ? and merchant_type_code = 'carWash' and deleted = 0
                """, merchantId);
        List<Map<String, Object>> serviceRows = query("""
                select id, talent_levels, service_point_text, tip_text, external_link_type,
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
@@ -109,8 +109,8 @@
        if (serviceId == null || serviceId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "学生托付服务信息不能为空");
        List<Map<String, Object>> rows = query("""
                select m.id, m.merchant_rights_config, m.usage_instructions
                from merchant m join premium_topic_merchant mapping on mapping.merchant_id = m.id
                where m.id = ? and m.deleted = 0 and mapping.deleted = 0 and mapping.category_key = 'children'
                from merchant m
                where m.id = ? and m.deleted = 0 and m.merchant_type_code = 'children'
                """, serviceId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "学生托付服务不存在");
        return rows.get(0);
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
@@ -205,9 +205,9 @@
    private Map<String, Object> cinema(Long merchantId, boolean requireAvailable) {
        List<Map<String, Object>> merchants = query("""
                select id, merchant_name, merchant_type, contact_mobile, address, cover_url, service_label, rights_text,
                select id, merchant_name, merchant_type_code as merchant_type, contact_mobile, address, cover_url, service_label, rights_text,
                       merchant_rights_config, latitude, longitude, status
                from merchant where id = ? and deleted = 0 and merchant_type in ('cinema', 'theatre', '观影服务', '电影院', '影院', '影城', '剧院')
                from merchant where id = ? and deleted = 0 and merchant_type_code = 'cinema'
                """, merchantId);
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "影城不存在");
        Map<String, Object> merchant = merchants.get(0);
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java
@@ -32,9 +32,8 @@
                       detail_config.advisor_department, detail_config.advisor_mobile, detail_config.advisor_service_time,
                       detail_config.advisor_available, detail_config.unavailable_reason, detail_config.status as detail_status
                from merchant m
                join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.category_key = 'finance' and mapping.deleted = 0
                left join finance_service_detail detail_config on detail_config.merchant_id = m.id and detail_config.deleted = 0
                where m.id = ? and m.merchant_type in ('finance', '金融服务') and m.deleted = 0
                where m.id = ? and m.merchant_type_code = 'finance' and m.deleted = 0
                order by detail_config.id asc limit 1
                """, merchantId);
        boolean available = number(row.get("merchantStatus")) == 1 && (row.get("detailStatus") == null || number(row.get("detailStatus")) == 1);
talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java
@@ -100,9 +100,9 @@
    private Map<String, Object> fuelMerchant(Long merchantId) {
        return one("""
                select id, merchant_name, merchant_type, contact_mobile, address, cover_url, service_label, status, latitude, longitude
                select id, merchant_name, merchant_type_code as merchant_type, contact_mobile, address, cover_url, service_label, status, latitude, longitude
                from merchant
                where id = ? and merchant_type in ('fuel', '加油服务') and deleted = 0
                where id = ? and merchant_type_code = 'fuel' and deleted = 0
                """, merchantId);
    }
talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java
@@ -88,9 +88,8 @@
    private Map<String, Object> medicalMerchant(Long merchantId) {
        if (merchantId == null || merchantId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "体检医院信息不能为空");
        List<Map<String, Object>> rows = query("""
                select m.id from merchant m join premium_topic_merchant mapping on mapping.merchant_id = m.id
                where m.id = ? and m.merchant_type in ('medical', '免费体检', 'healthCheck') and m.deleted = 0
                  and mapping.category_key = 'medical' and mapping.deleted = 0
                select m.id from merchant m
                where m.id = ? and m.merchant_type_code = 'medical' and m.deleted = 0
                """, merchantId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "体检医院不存在");
        return rows.get(0);
@@ -111,8 +110,7 @@
                       m.merchant_name as hospital_name,
                       m.address as hospital_address, m.contact_mobile as hospital_mobile, m.latitude as hospital_latitude,
                       m.longitude as hospital_longitude
                from merchant_product p join merchant m on m.id = p.merchant_id and m.deleted = 0
                join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.category_key = 'medical' and mapping.deleted = 0
                from merchant_product p join merchant m on m.id = p.merchant_id and m.deleted = 0 and m.merchant_type_code = 'medical'
                where p.id = ? and p.deleted = 0 and p.audit_status = 2
                """, packageId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "体检套餐不存在");
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java
@@ -137,7 +137,7 @@
                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 in ('parking', '停车服务') and deleted = 0
                where id = ? and merchant_type_code = 'parking' and deleted = 0
                """, merchantId);
    }
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -6,14 +6,16 @@
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Collections;
import java.util.Locale;
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.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.jsoup.Jsoup;
@@ -65,7 +67,7 @@
                limit 8
                """));
        result.put("recommendedMerchants", query("""
                select id, merchant_name, merchant_type, logo_url, cover_url, address
                select id, merchant_name, merchant_type_code as merchant_type, logo_url, cover_url, address
                from merchant
                where deleted = 0 and status = 1 and is_recommend = 1
                order by sort_no desc, id desc
@@ -225,7 +227,7 @@
            }
        }
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       is_recommend, sort_no, longitude, latitude, business_status,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
@@ -255,9 +257,9 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> foodMerchants(int page, int size, Long talentId) {
        String merchantWhere = " where deleted = 0 and merchant_type in ('food', '餐饮服务')";
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'food'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       status, sort_no, longitude, latitude, business_status,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
@@ -293,9 +295,9 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> travelMerchants(int page, int size) {
        String merchantWhere = " where deleted = 0 and merchant_type in ('scenic', '景区服务')";
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'scenic'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       status, sort_no, longitude, latitude, business_status,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
@@ -334,18 +336,16 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size, Long talentId) {
        String requestedType = type == null ? "" : type.trim();
        String requestedType = MerchantTypeCatalog.canonicalCode(type);
        boolean apartment = switch (requestedType) {
            case "hotel", "酒店服务" -> false;
            case "apartment", "公寓租赁", "公寓租赁服务" -> true;
            case "hotel" -> false;
            case "apartment" -> true;
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的住宿服务类型");
        };
        String responseMerchantType = apartment ? "apartment" : "hotel";
        String merchantWhere = apartment
                ? " where deleted = 0 and merchant_type in ('apartment', '公寓租赁', '公寓租赁服务')"
                : " where deleted = 0 and merchant_type in ('hotel', '酒店服务')";
        String merchantWhere = " where deleted = 0 and merchant_type_code = '" + responseMerchantType + "'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       status, sort_no, longitude, latitude, business_status, group_buying_enabled, merchant_rights_config,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
@@ -399,9 +399,9 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> entertainmentMerchants(int page, int size) {
        String merchantWhere = " where deleted = 0 and merchant_type in ('cinema', 'theatre', '观影服务', '电影院', '影院', '影城', '剧院')";
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'cinema'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       service_label, status, sort_no, longitude, latitude, business_status,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
@@ -437,18 +437,13 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> transportMerchants(String type, int page, int size) {
        if (!List.of("fuel", "parking", "carWash").contains(type)) {
        String requestedType = MerchantTypeCatalog.canonicalCode(type);
        if (!List.of("fuel", "parking", "carWash").contains(requestedType)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
        }
        String requestedType = type;
        String merchantWhere = switch (type) {
            case "fuel" -> " where deleted = 0 and merchant_type in ('fuel', '加油服务')";
            case "parking" -> " where deleted = 0 and merchant_type in ('parking', '停车服务')";
            case "carWash" -> " where deleted = 0 and merchant_type in ('carWash', '自助洗车', '自助洗车服务')";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
        };
        String merchantWhere = " where deleted = 0 and merchant_type_code = '" + requestedType + "'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       service_label, status, sort_no, longitude, latitude, business_status, merchant_rights_config,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
@@ -500,26 +495,24 @@
                where category_key = ? and enabled = 1 and deleted = 0
                """, Integer.class, categoryId);
        if (categoryCount == 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的购物服务分类");
        String merchantTypeFilter = switch (categoryId) {
            case "housing" -> "('购房优惠')";
            case "car" -> "('购车服务')";
            case "appliance" -> "('家电服务')";
            case "digital" -> "('数码服务')";
            case "book" -> "('文化服务', '图书服务')";
            default -> "('')";
        };
        List<String> merchantTypeCodes = MerchantTypeCatalog.codesForShoppingCategory(categoryId);
        if (merchantTypeCodes.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "该购物分类未配置商家类型");
        String placeholders = String.join(",", java.util.Collections.nCopies(merchantTypeCodes.size(), "?"));
        String merchantWhere = """
                where m.deleted = 0
                  and (m.merchant_type in %s or (m.merchant_type = 'shopping' and mapping.deleted = 0 and mapping.category_key = ?))
                """.formatted(merchantTypeFilter);
                  and m.merchant_type_code in (%s)
                """.formatted(placeholders);
        List<Object> args = new ArrayList<>();
        args.add(categoryId);
        args.addAll(merchantTypeCodes);
        PageResult<Map<String, Object>> result = page("""
                select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile, m.logo_url, m.cover_url, m.address,
                select m.id, m.merchant_name, m.merchant_type_code as merchant_type, m.contact_name, m.contact_mobile, m.logo_url, m.cover_url, m.address,
                       m.service_label, m.status, m.sort_no, m.longitude, m.latitude, m.business_status
                from merchant m
                left join shopping_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ?
                """ + merchantWhere + " order by mapping.sort_no asc, m.sort_no asc, m.id asc",
                "select count(*) from merchant m left join shopping_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ? " + merchantWhere,
                page, size, categoryId, categoryId);
                page, size, args.toArray());
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("discountText", textOrDefault(item.get("serviceLabel"), "人才专享"));
@@ -561,49 +554,27 @@
                where category_key = ? and enabled = 1 and deleted = 0
                """, Integer.class, categoryId);
        if (categoryCount == null || categoryCount == 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的臻享服务分类");
        String fallbackMerchantType = premiumFallbackMerchantType(categoryId);
        String serviceWhere = """
                where m.deleted = 0 and (
                    (mapping.deleted = 0 and mapping.category_key = ?)
                    or (mapping.id is null and m.merchant_type = ?)
                )
                """;
        MerchantType merchantType = MerchantTypeCatalog.forPremiumCategory(categoryId)
                .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "该臻享分类未配置商家类型"));
        String serviceWhere = " where m.deleted = 0 and m.merchant_type_code = ?";
        PageResult<Map<String, Object>> result = page("""
                select m.id, m.merchant_name, m.merchant_type, m.contact_mobile, m.cover_url, m.address,
                select m.id, m.merchant_name, m.merchant_type_code as merchant_type, m.contact_mobile, m.cover_url, m.address,
                       m.service_label, m.status, m.sort_no, m.longitude, m.latitude,
                       coalesce(mapping.category_key, case m.merchant_type
                           when '免费体检' then 'medical' when '配偶推荐就业' then 'employment'
                           when '金融服务' then 'finance' when '行政审批' then 'approval'
                           when '学生托付' then 'children' when '子女托管' then 'children' end) as category_key,
                       coalesce(mapping.business_type, case m.merchant_type
                           when '免费体检' then 'MEDICAL' when '配偶推荐就业' then 'SPOUSE_EMPLOYMENT'
                           when '金融服务' then 'FINANCE' when '行政审批' then 'ADMIN_APPROVAL'
                           when '学生托付' then 'CHILD_ENTRUSTMENT' when '子女托管' then 'CHILD_ENTRUSTMENT' end) as business_type
                       mapping.sort_no as topic_sort_no
                from merchant m
                left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0
                left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ?
                """ + serviceWhere + " order by mapping.sort_no asc, m.sort_no asc, m.id asc",
                "select count(*) from merchant m left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 " + serviceWhere,
                page, size, categoryId, fallbackMerchantType);
                "select count(*) from merchant m left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ? " + serviceWhere,
                page, size, categoryId, merchantType.code());
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("businessType", row.get("businessType"));
            item.put("targetAction", row.get("businessType"));
            item.put("businessType", merchantType.premiumBusinessType());
            item.put("targetAction", merchantType.premiumBusinessType());
            item.put("benefitText", textOrDefault(row.get("serviceLabel"), "人才专享"));
            item.put("discountText", textOrDefault(row.get("serviceLabel"), "人才专享"));
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }
    private String premiumFallbackMerchantType(String categoryId) {
        return switch (categoryId) {
            case "medical" -> "免费体检";
            case "employment" -> "配偶推荐就业";
            case "finance" -> "金融服务";
            case "approval" -> "行政审批";
            case "children" -> "子女托管";
            default -> "";
        };
    }
    private Map<String, Object> enrichMerchantSearchRow(Map<String, Object> row) {
@@ -621,63 +592,30 @@
    }
    private String categoryName(String merchantType) {
        return switch (merchantType) {
            case "food", "餐饮服务" -> "暖心食享";
            case "hotel", "apartment" -> "舒心安居";
            case "cinema", "theatre" -> "怡乐生活";
            case "scenic", "景区服务" -> "悦览花城";
            case "fuel", "parking", "carWash" -> "畅行无忧";
            case "shopping" -> "惠享好物";
            case "medical", "finance" -> "臻享礼遇";
            default -> "其他权益";
        };
        return MerchantTypeCatalog.find(merchantType).map(MerchantType::topicName).orElse("其他权益");
    }
    private String appendMerchantTypeFilter(String merchantWhere, List<String> merchantTypes, List<Object> args) {
        if (merchantTypes.size() == 1) {
            args.add(merchantTypes.get(0));
            return merchantWhere + " and merchant_type = ?";
            return merchantWhere + " and merchant_type_code = ?";
        }
        args.addAll(merchantTypes);
        String placeholders = String.join(",", Collections.nCopies(merchantTypes.size(), "?"));
        return merchantWhere + " and merchant_type in (" + placeholders + ")";
        return merchantWhere + " and merchant_type_code in (" + placeholders + ")";
    }
    private List<String> merchantTypesForRequestedValue(String value) {
        List<String> categoryTypes = merchantTypesForCategory(value);
        return categoryTypes.isEmpty() ? List.of(value) : categoryTypes;
        List<String> types = MerchantTypeCatalog.codesForRequestedValue(value);
        return types.isEmpty() ? List.of("other") : types;
    }
    private List<String> merchantTypesForCategory(String value) {
        return switch (value) {
            case "暖心食享", "餐饮服务", "food" -> List.of("food", "餐饮服务");
            case "舒心安居", "hotel" -> List.of("hotel");
            case "怡乐生活", "cinema", "theatre" -> List.of("cinema", "theatre");
            case "悦览花城", "scenic", "景区服务" -> List.of("scenic", "景区服务");
            case "畅行无忧" -> List.of("fuel", "parking", "carWash");
            case "fuel" -> List.of("fuel");
            case "parking" -> List.of("parking");
            case "carWash" -> List.of("carWash");
            case "惠享好物", "shopping" -> List.of("shopping");
            case "臻享礼遇" -> List.of("medical", "finance");
            case "medical" -> List.of("medical");
            case "finance" -> List.of("finance");
            default -> List.of();
        };
        return MerchantTypeCatalog.codesForRequestedValue(value);
    }
    private String detailType(String merchantType) {
        return switch (merchantType) {
            case "cinema" -> "CINEMA";
            case "theatre" -> "THEATRE";
            case "shopping" -> "SHOPPING";
            case "medical" -> "MEDICAL";
            case "finance" -> "FINANCE";
            case "carWash" -> "CAR_WASH";
            case "fuel" -> "FUEL";
            case "parking" -> "PARKING";
            default -> "MERCHANT";
        };
        return MerchantTypeCatalog.find(merchantType).map(MerchantType::detailType).orElse("MERCHANT");
    }
    private String targetAction(String merchantType) {
@@ -705,7 +643,7 @@
    @Transactional(readOnly = true)
    public Map<String, Object> merchantDetail(Long id, Long talentId) {
        Map<String, Object> merchant = one("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       business_status, longitude, latitude, group_buying_enabled, usage_instructions, rights_text, merchant_rights_config,
                       service_label, status, is_recommend, sort_no
                from merchant
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
@@ -24,18 +24,17 @@
        Map<String, Object> row = one("""
                select m.id, m.merchant_name, m.cover_url, m.service_label, m.address, m.contact_mobile, m.contact_name, m.rights_text,
                       m.longitude, m.latitude, m.status as merchant_status,
                       coalesce(mapping.category_key, case m.merchant_type
                           when '购房优惠' then 'housing' when '购车服务' then 'car'
                           when '家电服务' then 'appliance' when '数码服务' then 'digital'
                           when '文化服务' then 'book' when '图书服务' then 'book' end) as category_key,
                       case m.merchant_type_code
                           when 'housing' then 'housing' when 'car' then 'car'
                           when 'appliance' then 'appliance' when 'digital' then 'digital'
                           when 'culture' then 'book' when 'telecom' then 'digital' end as category_key,
                       detail_config.talent_levels, detail_config.benefit_detail, detail_config.advisor_id,
                       detail_config.advisor_name, detail_config.advisor_department, detail_config.advisor_mobile,
                       detail_config.advisor_service_time, detail_config.advisor_available,
                       detail_config.unavailable_reason, detail_config.status as detail_status
                from merchant m
                left join shopping_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0
                left join shopping_service_detail detail_config on detail_config.merchant_id = m.id and detail_config.deleted = 0
                where m.id = ? and m.merchant_type in ('shopping', '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '图书服务') and m.deleted = 0
                where m.id = ? and m.merchant_type_code in ('shopping', 'housing', 'car', 'appliance', 'digital', 'culture', 'telecom') and m.deleted = 0
                order by detail_config.id asc limit 1
                """, merchantId);
        boolean available = number(row.get("merchantStatus")) == 1 && (row.get("detailStatus") == null || number(row.get("detailStatus")) == 1);
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java
@@ -114,8 +114,7 @@
                       m.merchant_rights_config
                from merchant m
                left join spouse_employment_benefit b on b.merchant_id = m.id and b.deleted = 0
                join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.category_key = 'employment' and mapping.deleted = 0
                where m.id = ? and m.deleted = 0
                where m.id = ? and m.deleted = 0 and m.merchant_type_code = 'employment'
                """, merchantId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "配偶推荐就业权益不存在");
        return rows.get(0);
talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
@@ -76,7 +76,7 @@
                   o.id as target_detail_id,
                   o.merchant_id,
                       coalesce(nullif(m.merchant_name, ''), '人才权益服务') as merchant_name,
                       coalesce(nullif(m.merchant_type, ''), 'service') as merchant_type,
                       coalesce(nullif(m.merchant_type_code, ''), 'service') as merchant_type,
                       coalesce(nullif(b.benefit_name, ''), o.order_type, '人才权益订单') as detail_name,
                       o.created_at as occurred_at,
                       case
talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java
@@ -16,15 +16,7 @@
            <script>
            select m.id as merchantId,
                   m.merchant_name as merchantName,
                   case
                     when m.merchant_type = 'parking' then 'parking'
                     when m.merchant_type = 'medical' then 'medical'
                     when m.merchant_type = 'apartment' then 'apartment'
                     when exists (select 1 from shopping_topic_merchant s1 where s1.merchant_id = m.id and s1.deleted = 0 and s1.category_key = 'housing') then 'housing'
                     when exists (select 1 from shopping_topic_merchant s2 where s2.merchant_id = m.id and s2.deleted = 0 and s2.category_key = 'car') then 'car'
                     when exists (select 1 from shopping_topic_merchant s3 where s3.merchant_id = m.id and s3.deleted = 0 and s3.category_key = 'appliance') then 'appliance'
                     else m.merchant_type
                   end as category,
                   m.merchant_type_code as category,
                   m.merchant_type as merchantType,
                   m.contact_name as contactName,
                   m.contact_mobile as contactMobile,
@@ -45,32 +37,9 @@
                   m.updated_at as updatedAt
            from merchant m
            where m.deleted = 0
              and (
                    m.merchant_type in ('parking', 'medical', 'apartment')
                    or exists (
                        select 1
                        from shopping_topic_merchant sm
                        where sm.merchant_id = m.id
                          and sm.deleted = 0
                          and sm.category_key in ('housing', 'car', 'appliance')
                    )
                  )
              and m.merchant_type_code in ('parking', 'medical', 'apartment', 'housing', 'car', 'appliance')
              <if test="category != null and category != ''">
                <choose>
                  <when test="category == 'housing' or category == 'car' or category == 'appliance'">
                    and m.merchant_type = 'shopping'
                    and exists (
                        select 1
                        from shopping_topic_merchant sm
                        where sm.merchant_id = m.id
                          and sm.deleted = 0
                          and sm.category_key = #{category}
                    )
                  </when>
                  <otherwise>
                    and m.merchant_type = #{category}
                  </otherwise>
                </choose>
                and m.merchant_type_code = #{category}
              </if>
              <if test="status != null">and m.status = #{status}</if>
              <if test="keyword != null and keyword != ''">
@@ -93,32 +62,9 @@
            select count(*)
            from merchant m
            where m.deleted = 0
              and (
                    m.merchant_type in ('parking', 'medical', 'apartment')
                    or exists (
                        select 1
                        from shopping_topic_merchant sm
                        where sm.merchant_id = m.id
                          and sm.deleted = 0
                          and sm.category_key in ('housing', 'car', 'appliance')
                    )
                  )
              and m.merchant_type_code in ('parking', 'medical', 'apartment', 'housing', 'car', 'appliance')
              <if test="category != null and category != ''">
                <choose>
                  <when test="category == 'housing' or category == 'car' or category == 'appliance'">
                    and m.merchant_type = 'shopping'
                    and exists (
                        select 1
                        from shopping_topic_merchant sm
                        where sm.merchant_id = m.id
                          and sm.deleted = 0
                          and sm.category_key = #{category}
                    )
                  </when>
                  <otherwise>
                    and m.merchant_type = #{category}
                  </otherwise>
                </choose>
                and m.merchant_type_code = #{category}
              </if>
              <if test="status != null">and m.status = #{status}</if>
              <if test="keyword != null and keyword != ''">
@@ -136,15 +82,7 @@
            <script>
            select m.id as merchantId,
                   m.merchant_name as merchantName,
                   case
                     when m.merchant_type = 'parking' then 'parking'
                     when m.merchant_type = 'medical' then 'medical'
                     when m.merchant_type = 'apartment' then 'apartment'
                     when exists (select 1 from shopping_topic_merchant s1 where s1.merchant_id = m.id and s1.deleted = 0 and s1.category_key = 'housing') then 'housing'
                     when exists (select 1 from shopping_topic_merchant s2 where s2.merchant_id = m.id and s2.deleted = 0 and s2.category_key = 'car') then 'car'
                     when exists (select 1 from shopping_topic_merchant s3 where s3.merchant_id = m.id and s3.deleted = 0 and s3.category_key = 'appliance') then 'appliance'
                     else m.merchant_type
                   end as category,
                   m.merchant_type_code as category,
                   m.merchant_type as merchantType,
                   m.contact_name as contactName,
                   m.contact_mobile as contactMobile,
@@ -166,18 +104,9 @@
            from merchant m
            where m.deleted = 0
              and m.id = #{serviceId}
              and (
                    m.merchant_type in ('parking', 'medical', 'apartment')
                    or exists (
                        select 1
                        from shopping_topic_merchant sm
                        where sm.merchant_id = m.id
                          and sm.deleted = 0
                          and sm.category_key in ('housing', 'car', 'appliance')
                    )
                  )
              and m.merchant_type_code in ('parking', 'medical', 'apartment', 'housing', 'car', 'appliance')
            limit 1
            </script>
            """)
    Map<String, Object> selectAdminDetail(@Param("serviceId") Long serviceId);
}
}
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java
@@ -37,7 +37,7 @@
            from merchant m
            where m.deleted = 0
              <if test="merchantId != null">and m.id = #{merchantId}</if>
              <if test="merchantType != null and merchantType != ''">and m.merchant_type = #{merchantType}</if>
              <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
              <if test="status != null">and m.status = #{status}</if>
              <if test="keyword != null and keyword != ''">
                and (m.merchant_name like concat('%', #{keyword}, '%')
@@ -73,7 +73,7 @@
            from merchant m
            where m.deleted = 0
              <if test="merchantId != null">and m.id = #{merchantId}</if>
              <if test="merchantType != null and merchantType != ''">and m.merchant_type = #{merchantType}</if>
              <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
              <if test="status != null">and m.status = #{status}</if>
              <if test="keyword != null and keyword != ''">
                and (m.merchant_name like concat('%', #{keyword}, '%')
@@ -164,7 +164,7 @@
            )
            where m.deleted = 0
              and m.status = 1
              <if test="merchantType != null and merchantType != ''">and m.merchant_type = #{merchantType}</if>
              <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
              <if test="keyword != null and keyword != ''">
                and (m.merchant_name like concat('%', #{keyword}, '%')
                     or m.contact_mobile like concat('%', #{keyword}, '%')
@@ -186,7 +186,7 @@
            from merchant m
            where m.deleted = 0
              and m.status = 1
              <if test="merchantType != null and merchantType != ''">and m.merchant_type = #{merchantType}</if>
              <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
              <if test="keyword != null and keyword != ''">
                and (m.merchant_name like concat('%', #{keyword}, '%')
                     or m.contact_mobile like concat('%', #{keyword}, '%')
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java
@@ -68,7 +68,7 @@
                  and o.order_status = 1
                  and o.verify_status = 1
                  <if test="merchantId != null">and o.merchant_id = #{merchantId}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type = #{merchantType}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
                  <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
            ) base
@@ -106,7 +106,7 @@
                  and o.order_status = 1
                  and o.verify_status = 1
                  <if test="merchantId != null">and o.merchant_id = #{merchantId}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type = #{merchantType}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
                  <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
                group by period_key, o.merchant_id
@@ -169,7 +169,7 @@
                  and o.order_status = 1
                  and o.verify_status = 1
                  <if test="merchantId != null">and o.merchant_id = #{merchantId}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type = #{merchantType}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
                  <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
            ) base
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java
@@ -64,7 +64,7 @@
                           where mapping.merchant_id = m.id
                             and mapping.category_key = #{categoryKey}
                             and mapping.deleted = 0)
                   or (#{categoryKey} = 'medical' and m.merchant_type in ('medical', '免费体检', 'healthCheck')))
                   or (#{categoryKey} = 'medical' and m.merchant_type_code = 'medical'))
            limit 1
            """)
    Map<String, Object> selectPremiumMerchantSnapshot(@Param("merchantId") Long merchantId, @Param("categoryKey") String categoryKey);
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashCredentialMapper.java
@@ -99,7 +99,7 @@
            """)
    Map<String, Object> selectPrimaryCard(@Param("familyCardId") Long familyCardId);
    @Select("select count(*) from merchant where id = #{merchantId} and merchant_type = 'carWash' and deleted = 0 and status = 1")
    @Select("select count(*) from merchant where id = #{merchantId} and merchant_type_code = 'carWash' and deleted = 0 and status = 1")
    Long countActiveMerchant(@Param("merchantId") Long merchantId);
    @Select("""
@@ -112,4 +112,4 @@
    @Select("select * from transport_car_wash_credential where id = #{id} and deleted = 0 limit 1 for update")
    TransportCarWashCredential selectByIdForUpdate(@Param("id") Long id);
}
}
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java
@@ -29,7 +29,7 @@
            from merchant m
            left join transport_car_wash_service s on s.merchant_id = m.id and s.deleted = 0
            where m.deleted = 0
              and m.merchant_type = '自助洗车'
              and m.merchant_type_code = 'carWash'
              and (#{keyword} is null or #{keyword} = '' or m.merchant_name like concat('%', #{keyword}, '%')
                   or m.address like concat('%', #{keyword}, '%') or m.contact_mobile like concat('%', #{keyword}, '%'))
              and (#{merchantId} is null or m.id = #{merchantId})
@@ -45,7 +45,7 @@
            select count(*)
            from merchant m
            where m.deleted = 0
              and m.merchant_type = '自助洗车'
              and m.merchant_type_code = 'carWash'
              and (#{keyword} is null or #{keyword} = '' or m.merchant_name like concat('%', #{keyword}, '%')
                   or m.address like concat('%', #{keyword}, '%') or m.contact_mobile like concat('%', #{keyword}, '%'))
              and (#{merchantId} is null or m.id = #{merchantId})
@@ -69,7 +69,7 @@
            from merchant m
            left join transport_car_wash_service s on s.merchant_id = m.id and s.deleted = 0
            where m.deleted = 0
              and m.merchant_type = '自助洗车'
              and m.merchant_type_code = 'carWash'
              and m.id = #{merchantId}
            limit 1
            """)
@@ -90,7 +90,7 @@
            from merchant m
            left join transport_car_wash_service s on s.merchant_id = m.id and s.deleted = 0
            where m.deleted = 0
              and m.merchant_type = '自助洗车'
              and m.merchant_type_code = 'carWash'
              and m.id = #{merchantId}
            limit 1
            """)
@@ -99,7 +99,7 @@
    @Select("""
            select count(*)
            from merchant
            where deleted = 0 and merchant_type = '自助洗车' and id = #{merchantId}
            where deleted = 0 and merchant_type_code = 'carWash' and id = #{merchantId}
            """)
    Long countCarWashMerchant(@Param("merchantId") Long merchantId);
}
}
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java
@@ -36,7 +36,7 @@
                   b.action_mode as actionMode,
                   b.status as benefitStatus
            from transport_parking_card_level_right r
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type in ('parking', '停车服务')
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type_code = 'parking'
            left join transport_parking_benefit b on b.merchant_id = m.id and b.deleted = 0
            where r.deleted = 0
              and (#{merchantId} is null or r.merchant_id = #{merchantId})
@@ -57,7 +57,7 @@
    @Select("""
            select count(*)
            from transport_parking_card_level_right r
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type in ('parking', '停车服务')
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type_code = 'parking'
            where r.deleted = 0
              and (#{merchantId} is null or r.merchant_id = #{merchantId})
              and (#{cardLevel} is null or #{cardLevel} = '' or r.card_level = #{cardLevel})
@@ -93,7 +93,7 @@
                   b.action_mode as actionMode,
                   b.status as benefitStatus
            from transport_parking_card_level_right r
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type in ('parking', '停车服务')
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type_code = 'parking'
            left join transport_parking_benefit b on b.merchant_id = m.id and b.deleted = 0
            where r.deleted = 0
              and r.merchant_id = #{merchantId}
@@ -125,7 +125,7 @@
                   b.action_mode as actionMode,
                   b.status as benefitStatus
            from transport_parking_card_level_right r
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type in ('parking', '停车服务') and m.status = 1
            join merchant m on m.id = r.merchant_id and m.deleted = 0 and m.merchant_type_code = 'parking' and m.status = 1
            join transport_parking_benefit b on b.merchant_id = m.id and b.deleted = 0 and b.status = 1
            where r.deleted = 0
              and r.enabled = 1
@@ -135,4 +135,4 @@
            order by m.sort_no desc, m.id desc
            """)
    List<Map<String, Object>> selectAvailableForCard(@Param("cardLevel") String cardLevel, @Param("merchantId") Long merchantId);
}
}
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingPlateRegistrationMapper.java
@@ -50,7 +50,7 @@
            left join transport_parking_benefit b on b.merchant_id = m.id and b.deleted = 0
            where m.id = #{merchantId}
              and m.deleted = 0
              and m.merchant_type in ('parking', '停车服务')
              and m.merchant_type_code = 'parking'
            limit 1
            """)
    Map<String, Object> selectParkingMerchant(@Param("merchantId") Long merchantId);
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
@@ -549,7 +549,7 @@
                        .or()
                        .apply("exists (select 1 from benefit_order o "
                                + "join merchant m on m.id = o.merchant_id and m.deleted = 0 "
                                + "and m.merchant_type in ('hotel', '酒店服务') "
                                + "and m.merchant_type_code = 'hotel' "
                                + "where o.id = verify_record.order_id and o.deleted = 0)"));
        if (merchantId != null) {
            recordQuery.and(wrapper -> wrapper.eq("merchant_id", merchantId)
@@ -565,7 +565,7 @@
                .eq("verify_status", 1)
                .ge("updated_at", windowStart)
                .apply("exists (select 1 from merchant m where m.id = benefit_order.merchant_id "
                        + "and m.deleted = 0 and m.merchant_type in ('hotel', '酒店服务'))")
                        + "and m.deleted = 0 and m.merchant_type_code = 'hotel')")
                .apply("not exists (select 1 from verify_record r where r.order_id = benefit_order.id and r.status = 1)");
        if (merchantId != null) {
            orderQuery.eq("merchant_id", merchantId);
talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyAdminService.java
@@ -12,6 +12,7 @@
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.MerchantTypeCatalog;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.MerchantCardBenefit;
@@ -53,7 +54,7 @@
        List<Map<String, Object>> records = merchantMapper.selectAdminPage(
                merchantId,
                clean(keyword),
                clean(merchantType),
                merchantTypeCode(merchantType),
                aliases,
                status,
                safeSize,
@@ -63,7 +64,7 @@
        List<Map<String, Object>> views = records.stream()
                .map(row -> adminView(row, benefits.getOrDefault(longValue(row, "merchantId"), List.of())))
                .toList();
        Long total = merchantMapper.countAdminPage(merchantId, clean(keyword), clean(merchantType), aliases, status);
        Long total = merchantMapper.countAdminPage(merchantId, clean(keyword), merchantTypeCode(merchantType), aliases, status);
        return new PageResult<>(views, safePage, safeSize, total == null ? 0 : total);
    }
@@ -177,6 +178,11 @@
        }
    }
    private String merchantTypeCode(String value) {
        String cleaned = clean(value);
        return cleaned.isBlank() ? "" : MerchantTypeCatalog.canonicalCode(cleaned);
    }
    private void upsertPolicies(Long merchantId, List<MerchantDisplayPolicySaveRequest.CardPolicy> policies) {
        if (policies == null) {
            return;
talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyPortalService.java
@@ -9,6 +9,7 @@
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.MerchantTypeCatalog;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.BenefitFamilyCardApplication;
@@ -49,12 +50,12 @@
        List<String> aliases = cardLevelAliases(card.cardLevel());
        List<Map<String, Object>> records = merchantMapper.selectPortalPage(
                aliases,
                clean(merchantType),
                merchantTypeCode(merchantType),
                clean(keyword),
                safeSize,
                (safePage - 1) * safeSize
        ).stream().map(row -> portalView(row, card)).toList();
        Long total = merchantMapper.countPortalPage(aliases, clean(merchantType), clean(keyword));
        Long total = merchantMapper.countPortalPage(aliases, merchantTypeCode(merchantType), clean(keyword));
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
@@ -69,6 +70,11 @@
        return portalView(row, card);
    }
    private String merchantTypeCode(String value) {
        String cleaned = clean(value);
        return cleaned.isBlank() ? "" : MerchantTypeCatalog.canonicalCode(cleaned);
    }
    @Transactional
    public Map<String, Object> recordEvent(Long merchantId, MerchantDisplayPolicyEventRequest request) {
        Long id = requireMerchantId(merchantId);
talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java
@@ -15,6 +15,7 @@
import java.util.Map;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.MerchantTypeCatalog;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.mapper.MerchantSettlementStatisticsMapper;
@@ -55,7 +56,7 @@
        int safeSize = Math.min(Math.max(size, 1), 100);
        List<Map<String, Object>> records = mapper.selectPeriodSummaries(
                        merchantId,
                        clean(merchantType),
                        merchantTypeCode(merchantType),
                        normalizedPeriod,
                        range.startTime(),
                        range.endTime(),
@@ -66,7 +67,7 @@
                .toList();
        Long total = mapper.countPeriodSummaries(
                merchantId,
                clean(merchantType),
                merchantTypeCode(merchantType),
                normalizedPeriod,
                range.startTime(),
                range.endTime()
@@ -78,7 +79,7 @@
    public Map<String, Object> overview(Long merchantId, String merchantType, String startDate, String endDate) {
        requireMerchantIdWhenSpecified(merchantId);
        DateRange range = range("DAY", startDate, endDate);
        List<Map<String, Object>> rows = mapper.selectMerchantOverviews(merchantId, clean(merchantType), range.startTime(), range.endTime());
        List<Map<String, Object>> rows = mapper.selectMerchantOverviews(merchantId, merchantTypeCode(merchantType), range.startTime(), range.endTime());
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("startDate", range.startDate() == null ? "" : range.startDate().toString());
        result.put("endDate", range.endDateInclusive() == null ? "" : range.endDateInclusive().toString());
@@ -92,6 +93,11 @@
        return overview(merchantId, null, currentMonth.atDay(1).toString(), currentMonth.atEndOfMonth().toString());
    }
    private String merchantTypeCode(String value) {
        String cleaned = clean(value);
        return cleaned.isBlank() ? "" : MerchantTypeCatalog.canonicalCode(cleaned);
    }
    @Transactional(readOnly = true)
    public Map<String, Object> dashboardCards(Long merchantId) {
        requireMerchantId(merchantId);
talent-card-server/src/main/resources/db/migration/V118__resync_premium_topic_merchant_mappings.sql
New file
@@ -0,0 +1,27 @@
insert into premium_topic_merchant(merchant_id, category_key, business_type, sort_no, deleted)
select m.id,
       case m.merchant_type
           when '免费体检' then 'medical'
           when '配偶推荐就业' then 'employment'
           when '金融服务' then 'finance'
           when '行政审批' then 'approval'
           when '学生托付' then 'children'
           when '子女托管' then 'children'
       end,
       case m.merchant_type
           when '免费体检' then 'MEDICAL'
           when '配偶推荐就业' then 'SPOUSE_EMPLOYMENT'
           when '金融服务' then 'FINANCE'
           when '行政审批' then 'ADMIN_APPROVAL'
           when '学生托付' then 'CHILD_ENTRUSTMENT'
           when '子女托管' then 'CHILD_ENTRUSTMENT'
       end,
       m.sort_no,
       0
from merchant m
where m.deleted = 0
  and m.merchant_type in ('免费体检', '配偶推荐就业', '金融服务', '行政审批', '学生托付', '子女托管')
on duplicate key update category_key = values(category_key),
                        business_type = values(business_type),
                        sort_no = values(sort_no),
                        deleted = 0;
talent-card-server/src/main/resources/db/migration/V119__add_canonical_merchant_type_code.sql
New file
@@ -0,0 +1,53 @@
alter table merchant
    add column merchant_type_code varchar(32) not null default 'other' after merchant_type;
update merchant
set merchant_type_code = case
    when merchant_type in ('food', '餐饮服务', '餐饮', '餐饮商家') then 'food'
    when merchant_type in ('scenic', '景区服务', '景区', '景点服务') then 'scenic'
    when merchant_type in ('hotel', '酒店服务', '酒店', '住宿服务') then 'hotel'
    when merchant_type in ('apartment', '公寓租赁', '公寓租赁服务', '公寓服务') then 'apartment'
    when merchant_type in ('cinema', 'theatre', '观影服务', '电影院', '影院', '影城', '剧院') then 'cinema'
    when merchant_type in ('fuel', '加油服务', '加油', '加油站') then 'fuel'
    when merchant_type in ('parking', '停车服务', '停车', '停车场') then 'parking'
    when merchant_type in ('carWash', 'carwash', '自主洗车', '自助洗车', '自助洗车服务', '洗车服务') then 'carWash'
    when merchant_type in ('medical', '免费体检', 'healthCheck', 'healthcheck', '体检服务', '医疗体检') then 'medical'
    when merchant_type in ('employment', '配偶推荐就业', '就业服务', '配偶就业') then 'employment'
    when merchant_type in ('finance', '金融服务', '金融') then 'finance'
    when merchant_type in ('approval', '行政审批', '行政审批服务', '政务服务') then 'approval'
    when merchant_type in ('children', '学生托付', '学生托管', '子女托管', '子女托管服务') then 'children'
    when merchant_type in ('housing', '购房优惠', '购房服务', '住房服务') then 'housing'
    when merchant_type in ('car', '购车服务', '购车优惠', '汽车服务') then 'car'
    when merchant_type in ('appliance', '家电服务', '家用电器服务') then 'appliance'
    when merchant_type in ('digital', '数码服务', '数码产品服务') then 'digital'
    when merchant_type in ('culture', '文化服务', '图书服务', '文化图书服务') then 'culture'
    when merchant_type in ('telecom', '电信服务', '通信服务') then 'telecom'
    when merchant_type in ('dental', '口腔治疗保健', '口腔服务', '口腔保健') then 'dental'
    when merchant_type in ('shopping', '购物服务', '惠享好物') then 'shopping'
    else 'other'
end;
-- Early demo data used a generic shopping type and stored the real type only in the
-- topic mapping. Convert it once so the mapping is no longer required for visibility.
update merchant m
set m.merchant_type_code = case (
    select mapping.category_key
    from shopping_topic_merchant mapping
    where mapping.merchant_id = m.id and mapping.deleted = 0
    order by mapping.id
    limit 1
)
    when 'housing' then 'housing'
    when 'car' then 'car'
    when 'appliance' then 'appliance'
    when 'digital' then 'digital'
    when 'book' then 'culture'
    else m.merchant_type_code
end
where m.merchant_type_code = 'shopping'
  and exists (
      select 1 from shopping_topic_merchant mapping
      where mapping.merchant_id = m.id and mapping.deleted = 0
  );
create index idx_merchant_type_code on merchant(merchant_type_code, deleted, status);
talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java
@@ -39,7 +39,7 @@
    }
    @Test
    void doesNotSupportMerchantAppLoginForUnconfiguredServiceTypes() {
        assertThat(service.supportsMerchantAppLogin("酒店服务")).isFalse();
    void supportsMerchantAppLoginForHotelMerchants() {
        assertThat(service.supportsMerchantAppLogin("酒店服务")).isTrue();
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java
New file
@@ -0,0 +1,65 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
class MerchantTypeCatalogTests {
    @Test
    void resolvesDisplayNamesCodesAndLegacyAliasesToOneCode() {
        assertThat(MerchantTypeCatalog.canonicalCode("免费体检")).isEqualTo("medical");
        assertThat(MerchantTypeCatalog.canonicalCode("medical")).isEqualTo("medical");
        assertThat(MerchantTypeCatalog.canonicalCode("healthCheck")).isEqualTo("medical");
        assertThat(MerchantTypeCatalog.canonicalCode("影院")).isEqualTo("cinema");
        assertThat(MerchantTypeCatalog.canonicalCode("公寓租赁服务")).isEqualTo("apartment");
    }
    @Test
    void exposesRoutingMetadataFromTheSameCatalog() {
        MerchantTypeCatalog.MerchantType medical = MerchantTypeCatalog.find("免费体检").orElseThrow();
        assertThat(medical.topicName()).isEqualTo("臻享礼遇");
        assertThat(medical.detailType()).isEqualTo("MEDICAL");
        assertThat(medical.premiumCategoryKey()).isEqualTo("medical");
        assertThat(medical.premiumBusinessType()).isEqualTo("MEDICAL");
        assertThat(MerchantTypeCatalog.codesForShoppingCategory("book")).containsExactly("culture");
        assertThat(MerchantTypeCatalog.find("酒店服务").orElseThrow().supportsMerchantAppLogin()).isTrue();
    }
    @Test
    void unknownTypesHaveAnExplicitFallbackCode() {
        assertThat(MerchantTypeCatalog.canonicalCode("未知类型")).isEqualTo("other");
        assertThat(MerchantTypeCatalog.find("未知类型")).isEmpty();
    }
    @Test
    void coversEveryMerchantTypeOfferedByTheAdmin() {
        Map<String, String> adminTypes = Map.ofEntries(
                Map.entry("免费体检", "medical"),
                Map.entry("配偶推荐就业", "employment"),
                Map.entry("金融服务", "finance"),
                Map.entry("行政审批", "approval"),
                Map.entry("子女托管", "children"),
                Map.entry("餐饮服务", "food"),
                Map.entry("酒店服务", "hotel"),
                Map.entry("公寓租赁", "apartment"),
                Map.entry("加油服务", "fuel"),
                Map.entry("停车服务", "parking"),
                Map.entry("自助洗车", "carWash"),
                Map.entry("景区服务", "scenic"),
                Map.entry("购房优惠", "housing"),
                Map.entry("购车服务", "car"),
                Map.entry("家电服务", "appliance"),
                Map.entry("数码服务", "digital"),
                Map.entry("文化服务", "culture"),
                Map.entry("电信服务", "telecom"),
                Map.entry("口腔治疗保健", "dental"),
                Map.entry("观影服务", "cinema")
        );
        adminTypes.forEach((displayName, code) ->
                assertThat(MerchantTypeCatalog.canonicalCode(displayName)).isEqualTo(code));
    }
}