shinnosuke
2026-09-17 38dd07dd9879924e40cdffb75384d5f6bc4669b4
更新....
1 文件已重命名
6个文件已修改
309 ■■■■■ 已修改文件
rencaika/pages/merchant/merchant.uvue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/plate-info/plate-info.uvue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/travel-benefits/travel-benefits.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V150__merchant_hero_image.sql 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue
@@ -270,6 +270,19 @@
        return level
    }
    const sameCardLevel = (configured: any, actual: string): boolean => normalizeCardLevel(configured) == normalizeCardLevel(actual)
    // 商家可以按“卓越人才卡(本地)/(外地)”分开配置权益;人才地区已知时不能拿地区对不上的配置来展示。
    const levelAreaOf = (value: any): string => {
        const level = value == null ? '' : ('' + value).trim().toUpperCase()
        if (level.indexOf('外地') >= 0 || level.indexOf('OUTSIDE') >= 0) return 'OUTSIDE'
        if (level.indexOf('本地') >= 0 || level.indexOf('LOCAL') >= 0) return 'LOCAL'
        return ''
    }
    const talentAreaOf = (data: any): string => {
        const raw = data != null && data.sourceAreaType != null ? ('' + data.sourceAreaType).trim().toUpperCase() : ''
        if (raw.indexOf('外地') >= 0 || raw.indexOf('OUTSIDE') >= 0) return 'OUTSIDE'
        if (raw.indexOf('本地') >= 0 || raw.indexOf('LOCAL') >= 0) return 'LOCAL'
        return ''
    }
    const isRightEnabled = (value: any): boolean => value === true || Number(value) == 1 || ('' + value).trim().toLowerCase() == 'true'
    const parseRightsConfig = (value: any): any[] => {
        if (value == null || value == '') return []
@@ -306,9 +319,12 @@
        if (cardLevel == '') return
        let matched: any = null
        let disabledMatched: any = null
        const talentArea = talentAreaOf(data)
        for (let index = 0; index < rights.length; index++) {
            const item: any = rights[index]
            if (!sameCardLevel(rightLevel(item), cardLevel)) continue
            const configuredArea = levelAreaOf(rightLevel(item))
            if (talentArea != '' && configuredArea != '' && configuredArea != talentArea) continue
            if (isRightEnabled(item != null ? item.enabled : null)) {
                matched = item
                break
rencaika/pages/plate-info/plate-info.uvue
@@ -12,15 +12,19 @@
        <view class="form-content">
            <view v-if="requiresParking" class="field-group parking-field" @tap.stop>
                <text class="field-label">停车场</text>
                <view class="input-box"><input v-model="parkingKeyword" placeholder="搜索停车场" placeholder-class="input-placeholder" confirm-type="search" @focus="openParkingResults" @input="handleParkingInput" /><image src="/static/benefits-assets/search.svg" mode="aspectFit" @tap="openParkingResults"></image></view>
                <view v-if="showParkingResults" class="parking-results">
                    <view v-if="parkingSearchLoading" class="parking-result-empty"><text>正在搜索停车场</text></view>
                    <scroll-view v-else-if="filteredParkingLots.length > 0" class="parking-result-scroll" scroll-y>
                        <view class="parking-result-item" v-for="item in filteredParkingLots" :key="item.id" @tap.stop="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view>
                    </scroll-view>
                    <view v-else class="parking-result-empty"><text>暂无匹配停车场</text></view>
                </view>
                <view class="input-box" @tap.stop="openParkingResults"><input v-model="parkingKeyword" placeholder="搜索停车场" placeholder-class="input-placeholder" confirm-type="search" @focus="openParkingResults" @confirm="openParkingResults" @input="handleParkingInput" /><view class="input-search-button"><image src="/static/benefits-assets/search.svg" mode="aspectFit"></image></view></view>
                <text v-if="parkingError != ''" class="field-error">{{parkingError}}</text>
            </view>
            <!-- 搜索结果必须挂在 .form-content 上(它 position:relative 且足够高)。
                 如果放在“停车场”字段里面,会被该字段自带的 overflow:hidden 裁掉,只会露出半行文字。 -->
            <view v-if="requiresParking && showParkingResults" class="parking-results" :style="'height:' + parkingResultsHeight">
                <view v-if="parkingSearchLoading" class="parking-result-empty"><text>正在搜索停车场</text></view>
                <view v-else-if="filteredParkingLots.length > 0" class="parking-result-list">
                    <view class="parking-result-item" v-for="item in visibleParkingLots" :key="item.id" @tap.stop="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view>
                    <view v-if="filteredParkingLots.length > visibleParkingLots.length" class="parking-result-more"><text>还有 {{filteredParkingLots.length - visibleParkingLots.length}} 个停车场,请继续输入关键词</text></view>
                </view>
                <view v-else class="parking-result-empty"><text>暂无匹配停车场</text></view>
            </view>
            <view class="field-group plate-field" :class="requiresParking ? 'with-parking' : ''">
@@ -84,6 +88,22 @@
        { id: 8, name: '五十四交通银行旁停车场', address: '供销社背后金星巷3号' }
    ]
    const filteredParkingLots = ref(parkingLots)
    // 下拉最多展示 5 条:再多就提示继续输入关键词,避免下拉把页面撑爆或需要内部滚动。
    const parkingResultLimit = 5
    const visibleParkingLots = computed((): any[] => {
        const all: any[] = filteredParkingLots.value as any[]
        return all.slice(0, parkingResultLimit)
    })
    // 下拉高度由 JS 明确算出来(列表 + 可能出现的“还有 N 个”提示行 + 上下边框),
    // 不依赖容器自动撑高,避免部分机型上下拉被压扁只露出半行。
    const parkingResultsHeight = computed((): string => {
        if (parkingSearchLoading.value) return '116rpx'
        const list: any[] = visibleParkingLots.value as any[]
        if (list.length == 0) return '116rpx'
        let height = list.length * 92
        if (filteredParkingLots.value.length > list.length) height += 64
        return '' + (height + 4) + 'rpx'
    })
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -152,8 +172,18 @@
            })
        }, 250)
    }
    const openParkingResults = () => { showParkingResults.value = true; scheduleParkingSearch(parkingKeyword.value.trim()) }
    const closeParkingResults = () => { showParkingResults.value = false }
    // 打开下拉的瞬间,同一次点击可能会冒泡到页面根的关闭处理;
    // 记录打开时间,短时间内到达的关闭请求忽略,避免"点了搜索没反应"。
    let parkingResultsOpenedAt = 0
    const openParkingResults = () => {
        parkingResultsOpenedAt = Date.now()
        showParkingResults.value = true
        scheduleParkingSearch(parkingKeyword.value.trim())
    }
    const closeParkingResults = () => {
        if (Date.now() - parkingResultsOpenedAt < 250) return
        showParkingResults.value = false
    }
    const handleParkingInput = (event: any) => {
        const keyword = event.detail.value as string
        parkingKeyword.value = keyword
@@ -222,14 +252,18 @@
    .back-button image { width: 36rpx; height: 36rpx; }
    .nav-title { position: absolute; left: 50%; color: #171B22; font-size: 37rpx; line-height: 48rpx; font-weight: 700; transform: translateX(-50%); white-space: nowrap; }
    .capsule-space { height: 100%; flex-shrink: 0; }
    .form-content { padding: 47rpx 54rpx 0; }
    /* relative 供搜索结果下拉定位;overflow 放开,避免把下拉裁掉。 */
    .form-content { position: relative; padding: 47rpx 54rpx 0; overflow: visible; }
    .field-group { display: flex; position: relative; flex-direction: column; }
    .parking-field { z-index: 20; }
    .parking-field { z-index: 20; overflow: visible; }
    .plate-field { z-index: 1; }
    .field-label { color: #70798A; font-size: 25rpx; line-height: 36rpx; font-weight: 600; }
    .input-box { display: flex; height: 86rpx; margin-top: 17rpx; box-sizing: border-box; padding: 0 24rpx; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; }
    .input-box input { height: 82rpx; min-width: 0; flex: 1; color: #2D333C; font-size: 28rpx; }
    .input-box image { width: 39rpx; height: 39rpx; margin-left: 16rpx; flex-shrink: 0; }
    /* 放大镜的点击区域放大到整块右侧区域,避免小图标点不中。 */
    .input-search-button { display: flex; width: 72rpx; height: 86rpx; margin-left: 6rpx; margin-right: -14rpx; flex-shrink: 0; justify-content: center; align-items: center; }
    .input-search-button image { width: 39rpx; height: 39rpx; }
    .input-placeholder { color: #BBC1CA; font-size: 27rpx; }
    .field-error { margin-top: 9rpx; color: #F05C3A; font-size: 22rpx; }
    .plate-field { margin-top: 83rpx; }
@@ -250,11 +284,16 @@
    .submit-button { display: flex; height: 86rpx; border-radius: 44rpx; background-image: linear-gradient(90deg, #FF4B32 0%, #FF8A1E 100%); justify-content: center; align-items: center; }
    .submit-button text { color: #FFFFFF; font-size: 29rpx; line-height: 40rpx; font-weight: 600; }
    .submit-tip { display: block; margin-top: 31rpx; color: #A6ADB8; font-size: 21rpx; line-height: 32rpx; text-align: center; }
    .parking-results { position: absolute; z-index: 30; left: 0; right: 0; top: 140rpx; overflow: hidden; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 30rpx rgba(62,73,90,.12); }
    .parking-result-scroll { width: 100%; height: 312rpx; }
    .parking-result-item { display: flex; min-height: 78rpx; box-sizing: border-box; padding: 13rpx 22rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #EEF0F4; flex-direction: column; justify-content: center; }
    .parking-result-name { color: #333A45; font-size: 25rpx; line-height: 34rpx; }
    .parking-result-address { margin-top: 3rpx; color: #929AA7; font-size: 20rpx; line-height: 29rpx; }
    /* 挂在 .form-content 上:left/right 用表单左右内边距,正好和输入框等宽;
       top = 47rpx(表单上边距) + 36rpx(标签) + 17rpx(间距) + 86rpx(输入框) = 186rpx,即输入框正下方。 */
    .parking-results { position: absolute; z-index: 30; left: 54rpx; right: 54rpx; top: 186rpx; overflow: visible; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 30rpx rgba(62,73,90,.12); }
    /* 高度由 parkingResultsHeight 内联给出;列表只负责把条目排开。 */
    .parking-result-list { display: flex; flex-direction: column; }
    .parking-result-item { display: flex; height: 92rpx; min-height: 92rpx; box-sizing: border-box; padding: 13rpx 22rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #EEF0F4; flex-direction: column; flex-shrink: 0; justify-content: center; }
    .parking-result-name { color: #333A45; font-size: 25rpx; line-height: 34rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .parking-result-address { margin-top: 3rpx; color: #929AA7; font-size: 20rpx; line-height: 29rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .parking-result-more { display: flex; height: 64rpx; justify-content: center; align-items: center; }
    .parking-result-more text { color: #A4ABB5; font-size: 21rpx; }
    .parking-result-empty { display: flex; height: 112rpx; justify-content: center; align-items: center; }
    .parking-result-empty text { color: #A4ABB5; font-size: 23rpx; }
</style>
rencaika/pages/travel-benefits/travel-benefits.uvue
@@ -174,4 +174,8 @@
    .service-section { padding: 48rpx 31rpx 0; } .section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; } .section-heading text { color: #363636; font-size: 32rpx; font-weight: 700; } .section-heading view { width: 61rpx; height: 7rpx; margin-top: 12rpx; border-radius: 4rpx; background-image: linear-gradient(90deg,#FF4D36,#FFBF3A); }
    .service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; } .service-item.is-unavailable { opacity: .56; } .service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; } .service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; } .service-name { color: #292929; font-size: 29rpx; font-weight: 700; } .service-tags { display: flex; flex-direction: row; align-items: center; } .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; } .service-discount { display: block; width: fit-content; margin-left: 0; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF6A34; font-size: 21rpx; } .service-tags .service-discount { margin-left: 11rpx; } .service-tags .coupon-tag + .service-discount { margin-left: 11rpx; } .address { display: flex; min-width: 0; padding: 7rpx 10rpx; border-radius: 7rpx; background-color: #F1F2F3; } .address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; } .address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; }
    .list-state, .no-more { display: flex; height: 150rpx; color: #989DA5; font-size: 23rpx; justify-content: center; align-items: center; } .list-state.compact { height: 90rpx; } .error-state { flex-direction: column; } .retry-text, .load-error text { margin-top: 18rpx; color: #F26A38; } .loading-spinner { width: 26rpx; height: 26rpx; margin-right: 12rpx; box-sizing: border-box; border: 3rpx solid #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: loading 720ms linear infinite; } @keyframes loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
    /* 定位图标与地址必须同一行:uni-app x 的 view 默认纵向排列,这里显式改成横向并做单行省略。 */
    .address { flex-direction: row; align-items: center; }
    .address image { flex-shrink: 0; }
    .address text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -287,7 +287,7 @@
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'food'";
        PageResult<Map<String, Object>> result = page("""
                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,
                       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
                from merchant
@@ -298,9 +298,15 @@
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId);
            String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            Map<String, Object> right = matchingMerchantRight(
                    merchantRightsConfigs(row.get("merchantRightsConfig")),
                    activeProductCardLevel(talentId),
                    activeTalentSourceAreaType(talentId),
                    true);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "food", couponAvailable, discountText, null));
            item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "food", couponAvailable, discountText, right));
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -333,7 +339,7 @@
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'scenic'";
        PageResult<Map<String, Object>> result = page("""
                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,
                       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
                from merchant
@@ -345,9 +351,15 @@
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId);
            String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            Map<String, Object> right = matchingMerchantRight(
                    merchantRightsConfigs(row.get("merchantRightsConfig")),
                    activeProductCardLevel(talentId),
                    activeTalentSourceAreaType(talentId),
                    true);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "scenic", couponAvailable, discountText, null));
            item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "scenic", couponAvailable, discountText, right));
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -427,7 +439,7 @@
            return parkingBenefitTags(merchantId, rights, cardLevel, sourceAreaType);
        }
        if ("carWash".equals(merchantType)) {
            String tag = carWashListDiscountText(carWashExternalLinkType(rights), right);
            String tag = carWashDiscountTag(merchantId, talentId, rights, right);
            return tag.isBlank() ? List.of() : List.of(tag);
        }
        if (usesMerchantDetailHeaderTags(merchantType) || "cinema".equals(merchantType)) {
@@ -440,10 +452,11 @@
        if (shoppingType) {
            List<String> tags = new ArrayList<>();
            if (couponAvailable) tags.add("券");
            if (right != null) {
                String configuredDiscount = merchantRightDiscountText(right);
                if (!configuredDiscount.isBlank()) tags.add(configuredDiscount);
            }
            // 购物类同样取「团单折扣」与「商家权益折扣」中更低的一档。
            String configuredDiscount = right == null ? "" : merchantRightDiscountText(right);
            String productDiscount = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            String discountTag = lowerDiscountTag(productDiscount, configuredDiscount);
            if (!discountTag.isBlank()) tags.add(discountTag);
            return tags;
        }
@@ -472,26 +485,30 @@
        }
        List<String> tags = new ArrayList<>();
        if (couponAvailable) tags.add("券");
        String discount = textOrDefault(productDiscountText, "").trim();
        if ("人才专享".equals(discount)) discount = "";
        boolean hotelLike = "hotel".equals(type) || "apartment".equals(type);
        // 免费次数:酒店/公寓用商家权益里的免费券次数,其它类型用团单的免单次数。
        String freeTag = "";
        if (hotelLike && right != null && booleanValue(right.get("freeVoucherEnabled"))) {
            String times = textOrDefault(right.get("freeVoucherTimes"), "").trim();
            discount = times.isBlank() ? "免费" : "免费" + times + "次";
        } else {
            // 团单按人才等级配置了免单时,免费次数单独作为一个标签展示。
            String freeTimes = productFreeTimesTag(merchantId, cardLevel);
            if (!freeTimes.isBlank() && !tags.contains(freeTimes)) tags.add(freeTimes);
            freeTag = times.isBlank() ? "免费" : "免费" + times + "次";
        } else if (!hotelLike) {
            freeTag = productFreeTimesTag(merchantId, cardLevel);
        }
        if (!discount.isBlank() && !tags.contains(discount)) tags.add(discount);
        if (!freeTag.isBlank() && !tags.contains(freeTag)) tags.add(freeTag);
        // 折扣标签统一取「团单折扣」与「商家权益到店消费折扣」中更低的那档(数值越小折扣越大)。
        String discount = textOrDefault(productDiscountText, "").trim();
        if ("人才专享".equals(discount)) discount = "";
        String configuredDiscount = hotelLike ? consumeDiscountText(right) : (right == null ? "" : merchantRightDiscountText(right));
        String discountTag = lowerDiscountTag(discount, configuredDiscount);
        if (!discountTag.isBlank() && !tags.contains(discountTag)) tags.add(discountTag);
        // 酒店/公寓的到店消费如果配成"优惠"(减 X 元),它不是折扣,单独展示。
        if (hotelLike && right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
            String consumeType = textOrDefault(right.get("consumeType"), "");
            String consumeDiscount = textOrDefault(right.get("consumeDiscount"), "").trim();
            String consumeCoupon = textOrDefault(right.get("consumeCoupon"), "").trim();
            String consumeTag = "";
            if ("折扣".equals(consumeType) && !consumeDiscount.isBlank()) consumeTag = consumeDiscount + "折";
            else if ("优惠".equals(consumeType) && !consumeCoupon.isBlank()) consumeTag = "减" + consumeCoupon;
            if (!consumeTag.isBlank() && !tags.contains(consumeTag)) tags.add(consumeTag);
            if ("优惠".equals(consumeType) && !consumeCoupon.isBlank()) {
                String consumeTag = "减" + consumeCoupon;
                if (!tags.contains(consumeTag)) tags.add(consumeTag);
            }
        }
        return tags;
    }
@@ -591,7 +608,8 @@
                List<Map<String, Object>> rights = merchantRightsConfigs(item.get("merchantRightsConfig"));
                String externalLinkType = carWashExternalLinkType(rights);
                Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true);
                String discountText = carWashListDiscountText(externalLinkType, right);
                Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null;
                String discountText = carWashDiscountTag(merchantId, talentId, rights, right);
                item.put("externalLinkType", externalLinkType);
                item.put("discountText", discountText);
                item.put("benefitTagVisible", !discountText.isBlank());
@@ -717,6 +735,61 @@
        } catch (NumberFormatException ignored) {
            return "";
        }
    }
    /** 酒店、公寓的到店消费折扣:配在 consumeType=折扣 的 consumeDiscount 上。 */
    private String consumeDiscountText(Map<String, Object> right) {
        if (right == null) return "";
        if (!booleanValue(right.get("storeConsume")) || !booleanValue(right.get("consumeEnabled"))) return "";
        if (!"折扣".equals(textOrDefault(right.get("consumeType"), ""))) return "";
        String discount = textOrDefault(right.get("consumeDiscount"), "").trim();
        if (discount.isBlank()) return "";
        BigDecimal value = discountValue(discount + "折");
        return value == null ? "" : formatDiscount(value);
    }
    /**
     * 自助洗车折扣标签。
     *
     * 小程序跳转模式不打折扣标签(由外部小程序自己展示权益);普通模式取「团单折扣」与
     * 「商家权益折扣」中更低的那档。
     */
    private String carWashDiscountTag(Long merchantId, Long talentId, List<Map<String, Object>> rights, Map<String, Object> right) {
        String configured = carWashListDiscountText(carWashExternalLinkType(rights), right);
        if (configured.isBlank()) return "";
        String productDiscount = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
        return lowerDiscountTag(productDiscount, configured);
    }
    /**
     * 取两个折扣标签里更低的那档(数值越小折扣越大),统一格式化成 "X.Y折"。
     *
     * 团单折扣来自商品配置(如"超出免单次数后 7 折"),商家权益折扣来自后台"折扣"字段;
     * 两者都配了时按更低的一档展示,只有一个时用那一个,都不是数字则返回空。
     */
    private String lowerDiscountTag(String first, String second) {
        BigDecimal firstValue = discountValue(first);
        BigDecimal secondValue = discountValue(second);
        if (firstValue == null && secondValue == null) return "";
        if (firstValue == null) return formatDiscount(secondValue);
        if (secondValue == null) return formatDiscount(firstValue);
        return formatDiscount(firstValue.min(secondValue));
    }
    private BigDecimal discountValue(String tag) {
        if (tag == null) return null;
        String text = tag.trim().replace("折", "").trim();
        if (text.isBlank()) return null;
        try {
            BigDecimal value = new BigDecimal(text);
            return value.compareTo(BigDecimal.ZERO) > 0 && value.compareTo(BigDecimal.TEN) <= 0 ? value : null;
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private String formatDiscount(BigDecimal value) {
        return value.setScale(1, RoundingMode.HALF_UP).toPlainString() + "折";
    }
    private List<String> parkingBenefitTags(
@@ -979,7 +1052,7 @@
        merchant.put("consumeEnabled", consumeEnabled);
        merchant.put("benefitTags", merchantListBenefitTags(merchant, merchant, talentId));
        merchant.put("usageInstructions", merchantUsageInstructions(
                id, merchant.get("merchantRightsConfig"), cardLevel, merchant.get("usageInstructions")));
                id, merchant.get("merchantRightsConfig"), cardLevel, sourceAreaType, merchant.get("usageInstructions")));
        List<String> heroImages = new ArrayList<>();
        String coverUrl = textOrDefault(merchant.get("coverUrl"), "");
        if (!coverUrl.isBlank()) heroImages.add(coverUrl);
@@ -1001,14 +1074,35 @@
    private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, String cardLevel, String sourceAreaType, boolean requireEnabled) {
        if (cardLevel == null) return null;
        List<String> candidates = merchantRightCandidates(cardLevel, sourceAreaType);
        // 第一轮:完整等级名匹配。商家可以按“卓越人才卡(本地)/(外地)”分开配置,必须命中对应地区的那一条。
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) continue;
                if (candidate.equals(textOrDefault(right.get("level"), ""))) return right;
                if (sameCardLevel(right.get("level"), candidate)) return right;
            }
        }
        // 第二轮:退化成只比等级。人才地区已知时,不再使用地区对不上的配置
        // (否则本地人才会命中“(外地)”配置,显示外地权益内容)。
        String area = normalizedSourceAreaType(sourceAreaType);
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) continue;
                if (!sameCardLevel(right.get("level"), candidate)) continue;
                if (!areaMatchesConfiguredLevel(right.get("level"), area)) continue;
                return right;
            }
        }
        return null;
    }
    /** 人才地区已知时,带“(本地)/(外地)”后缀的商家配置必须与人才地区一致;没带后缀的配置不受限制。 */
    private boolean areaMatchesConfiguredLevel(Object configuredLevel, String area) {
        if (area == null || area.isBlank()) return true;
        String level = textOrDefault(configuredLevel, "").toUpperCase(Locale.ROOT);
        boolean configuredOutside = level.contains("外地") || level.contains("OUTSIDE");
        boolean configuredLocal = level.contains("本地") || level.contains("LOCAL");
        if (!configuredOutside && !configuredLocal) return true;
        return "OUTSIDE".equals(area) ? configuredOutside : ("LOCAL".equals(area) && configuredLocal);
    }
    private List<String> merchantRightCandidates(String cardLevel, String sourceAreaType) {
@@ -1033,11 +1127,14 @@
        };
    }
    private String merchantUsageInstructions(Long merchantId, Object rightsConfig, String cardLevel, Object fallback) {
    private String merchantUsageInstructions(Long merchantId, Object rightsConfig, String cardLevel, String sourceAreaType, Object fallback) {
        if (cardLevel != null) {
            String area = normalizedSourceAreaType(sourceAreaType);
            for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) {
                if (!booleanValue(right.get("enabled")) || !booleanValue(right.get("consumeEnabled"))
                        || !booleanValue(right.get("storeConsume")) || !sameCardLevel(right.get("level"), cardLevel)) continue;
                // “(本地)/(外地)”分开配置的商家,说明文案也要跟着人才地区走。
                if (!areaMatchesConfiguredLevel(right.get("level"), area)) continue;
                String description = textOrDefault(right.get("consumeDesc"), "");
                if (!description.isBlank()) return description;
            }
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
@@ -381,9 +381,9 @@
        List<String> candidates = cardLevelCandidates(cardLevel, primary.sourceAreaType());
        try {
            List<Map<String, Object>> rights = JSON_MAPPER.readValue(config, RIGHTS_TYPE);
            matched = matchingMerchantRight(rights, candidates, true);
            matched = matchingMerchantRight(rights, candidates, true, primary.sourceAreaType());
            if (matched == null) {
                disabledMatched = matchingMerchantRight(rights, candidates, false);
                disabledMatched = matchingMerchantRight(rights, candidates, false, primary.sourceAreaType());
            }
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "酒店商户权益配置格式不正确");
@@ -439,24 +439,46 @@
        return cardLevel == null || cardLevel.isBlank() ? List.of() : List.of(cardLevel);
    }
    private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, List<String> candidates, boolean requireEnabled) {
    private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, List<String> candidates, boolean requireEnabled, String sourceAreaType) {
        if (rights == null || rights.isEmpty() || candidates == null || candidates.isEmpty()) {
            return null;
        }
        // 第一轮:完整等级名匹配,按“(本地)/(外地)”分开配置时命中对应地区的那一条。
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) {
                    continue;
                }
                String configured = text(right.get("level"));
                if (candidate.equals(configured) || sameCardLevel(configured, candidate)) {
                    return right;
                if (candidate.equals(configured)) return right;
            }
        }
        // 第二轮:退化成只比等级,但人才地区已知时不能用地区对不上的配置。
        String area = normalizedSourceAreaType(sourceAreaType);
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) {
                    continue;
                }
                String configured = text(right.get("level"));
                if (!sameCardLevel(configured, candidate)) continue;
                if (!areaMatchesConfiguredLevel(configured, area)) continue;
                return right;
            }
        }
        return null;
    }
    /** 人才地区已知时,带“(本地)/(外地)”后缀的商家配置必须与人才地区一致;没带后缀的配置不受限制。 */
    private boolean areaMatchesConfiguredLevel(String configuredLevel, String area) {
        if (area == null || area.isBlank()) return true;
        String level = text(configuredLevel).toUpperCase(Locale.ROOT);
        boolean configuredOutside = level.contains("外地") || level.contains("OUTSIDE");
        boolean configuredLocal = level.contains("本地") || level.contains("LOCAL");
        if (!configuredOutside && !configuredLocal) return true;
        return "OUTSIDE".equals(area) ? configuredOutside : ("LOCAL".equals(area) && configuredLocal);
    }
    private Object firstValue(Map<String, Object> values, String... keys) {
        for (String key : keys) {
            Object value = values.get(key);
talent-card-server/src/main/resources/db/migration/V150__merchant_hero_image.sql
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
@@ -123,7 +123,8 @@
                "consumeDiscount", "8.5"));
        assertThat(foodTags).containsExactly("券", "8.8折");
        assertThat(hotelTags).containsExactly("券", "免费2次", "8.5折");
        // 酒店折扣同样取「团单折扣 7 折」与「商家权益到店消费折扣 8.5 折」中更低的一档。
        assertThat(hotelTags).containsExactly("券", "免费2次", "7.0折");
    }
    @Test
@@ -158,4 +159,36 @@
        assertThat(freeTags).containsExactly("免费");
        assertThat(paidTags).containsExactly("8.5折");
    }
    @Test
    @SuppressWarnings("unchecked")
    void merchantRightMatchingKeepsLocalAndOutsideRightsApart() throws Exception {
        Method method = PortalService.class.getDeclaredMethod(
                "matchingMerchantRight", List.class, String.class, String.class, boolean.class);
        method.setAccessible(true);
        List<Map<String, Object>> rights = List.of(
                Map.of("level", "卓越人才卡(外地)", "tag", "卓越人才卡(外地)", "detail", "外地权益", "enabled", true),
                Map.of("level", "卓越人才卡(本地)", "tag", "卓越人才卡(本地)", "detail", "本地权益", "enabled", true));
        Map<String, Object> local = (Map<String, Object>) method.invoke(service, rights, "卓越人才卡", "LOCAL", true);
        Map<String, Object> outside = (Map<String, Object>) method.invoke(service, rights, "卓越人才卡", "OUTSIDE", true);
        assertThat(local.get("detail")).isEqualTo("本地权益");
        assertThat(outside.get("detail")).isEqualTo("外地权益");
    }
    @Test
    void merchantRightMatchingNeverFallsBackToAnotherAreaScope() throws Exception {
        Method method = PortalService.class.getDeclaredMethod(
                "matchingMerchantRight", List.class, String.class, String.class, boolean.class);
        method.setAccessible(true);
        // 只配了“(外地)”时,本地人才拿不到这份权益;不带地区后缀的配置两边都能用。
        List<Map<String, Object>> outsideOnly = List.of(
                Map.of("level", "卓越人才卡(外地)", "detail", "外地权益", "enabled", true));
        List<Map<String, Object>> plain = List.of(Map.of("level", "卓越人才卡", "detail", "通用权益", "enabled", true));
        assertThat(method.invoke(service, outsideOnly, "卓越人才卡", "LOCAL", true)).isNull();
        assertThat(method.invoke(service, plain, "卓越人才卡", "LOCAL", true)).isNotNull();
        assertThat(method.invoke(service, plain, "卓越人才卡", "OUTSIDE", true)).isNotNull();
    }
}