From bc2efbdb52190fe2e7be9920bbed6902d4ee28aa Mon Sep 17 00:00:00 2001
From: shinnosuke <tantao@1hoper.com>
Date: 星期五, 21 八月 2026 11:53:37 +0800
Subject: [PATCH] fix: centralize merchant type integration

---
 talent-card-server/src/main/resources/db/migration/V118__resync_premium_topic_merchant_mappings.sql          |   27 +
 talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java                  |    2 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java |    4 
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java            |  155 +++++++++++
 talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java        |    6 
 talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java       |    8 
 talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashCredentialMapper.java          |    4 
 talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java             |   12 
 talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java                      |   87 -----
 talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java              |    4 
 talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java                      |    4 
 talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java      |   10 
 talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyAdminService.java        |   10 
 talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java      |   12 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java         |    2 
 talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java         |    4 
 talent-card-server/src/main/resources/db/migration/V119__add_canonical_merchant_type_code.sql                |   53 +++
 talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java              |   92 ++++--
 talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingPlateRegistrationMapper.java   |    2 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java    |    3 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java     |    2 
 talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyPortalService.java       |   10 
 talent-card-admin/src/views/MerchantCreateView.vue                                                           |    1 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java       |    5 
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java       |   65 ++++
 rencaika/pages/family-card-apply/family-card-apply.uvue                                                      |   18 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java                    |  158 +++-------
 talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java     |    3 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java        |    4 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java      |    8 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java    |   11 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java     |    2 
 32 files changed, 503 insertions(+), 285 deletions(-)

diff --git a/rencaika/pages/family-card-apply/family-card-apply.uvue b/rencaika/pages/family-card-apply/family-card-apply.uvue
index ae872a8..4aa4fd6 100644
--- a/rencaika/pages/family-card-apply/family-card-apply.uvue
+++ b/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
diff --git a/talent-card-admin/src/views/MerchantCreateView.vue b/talent-card-admin/src/views/MerchantCreateView.vue
index 53f9c31..1c598b7 100644
--- a/talent-card-admin/src/views/MerchantCreateView.vue
+++ b/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
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
index 4f05581..5a3e462 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
+++ b/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 = ?");
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java
new file mode 100644
index 0000000..be8be00
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java
@@ -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
+    ) {
+    }
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
index 0ee18d2..0a42c87 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java
index 270e1fc..b8ef75d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java
+++ b/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,
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
index 82e5c2c..8fd4b79 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
index 9c81915..60878a0 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java
index 680578d..b215121 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java
index f60ec6b..257a890 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java
+++ b/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);
 	}
 
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java
index 5d70a48..36e0e42 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java
+++ b/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, "体检套餐不存在");
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java
index 4d91d50..8c5e7b9 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java
+++ b/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);
 	}
 
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
index 5035b88..885a476 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
+++ b/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
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
index d8c82ce..66bf94f 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java
index b31f21b..2ab4eba 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
index 6fc08bd..0bb9b48 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
+++ b/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
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java
index 1533c4e..034f6e2 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java
+++ b/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);
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java
index ee689f5..e997143 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java
+++ b/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}, '%')
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java
index a3712c1..06b430e 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java
+++ b/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
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java
index 1ac90ab..7536768 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashCredentialMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashCredentialMapper.java
index 8831bce..6b26202 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashCredentialMapper.java
+++ b/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);
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java
index 16b74c5..3c33585 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java
+++ b/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);
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java
index ed78013..de2ce92 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java
+++ b/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);
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingPlateRegistrationMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingPlateRegistrationMapper.java
index 36ac13d..504a35d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingPlateRegistrationMapper.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java b/talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
index 3289f5d..b0bc1b1 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyAdminService.java b/talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyAdminService.java
index e185ad6..0d913bd 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyAdminService.java
+++ b/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;
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyPortalService.java
index 073e195..50c9963 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/MerchantDisplayPolicyPortalService.java
+++ b/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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java b/talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java
index f145f34..59782e9 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java
+++ b/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);
diff --git a/talent-card-server/src/main/resources/db/migration/V118__resync_premium_topic_merchant_mappings.sql b/talent-card-server/src/main/resources/db/migration/V118__resync_premium_topic_merchant_mappings.sql
new file mode 100644
index 0000000..6e85238
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V118__resync_premium_topic_merchant_mappings.sql
@@ -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;
diff --git a/talent-card-server/src/main/resources/db/migration/V119__add_canonical_merchant_type_code.sql b/talent-card-server/src/main/resources/db/migration/V119__add_canonical_merchant_type_code.sql
new file mode 100644
index 0000000..9a0fac8
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V119__add_canonical_merchant_type_code.sql
@@ -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);
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java
index e290f4a..e98e072 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java
+++ b/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();
 	}
 }
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java
new file mode 100644
index 0000000..957093d
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java
@@ -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));
+    }
+}

--
Gitblit v1.7.1