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(); } }