shinnosuke
2026-08-19 bdb42c17729c48de2385bd7ad24ba16920404f54
fix: connect miniapp benefit pages to admin config
13个文件已修改
214 ■■■■■ 已修改文件
rencaika/pages/children-entrustment/children-entrustment.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/children-info-edit/children-info-edit.uvue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/premium-benefits/premium-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/children-entrustment/children-entrustment.uvue
@@ -65,6 +65,7 @@
    const navRight = ref(10)
    const activeTab = ref(0)
    const serviceId = ref('')
    const talentId = ref('')
    const heroImage = ref('')
    const benefitTitle = ref('子女托管')
    const applicableCards = ref('')
@@ -86,6 +87,9 @@
        navRight.value = info.windowWidth - capsule.right
        // #endif
        serviceId.value = options != null && options['serviceId'] != null ? options['serviceId'] as string : ''
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        loadBenefit()
        loadSchools()
    })
@@ -93,7 +97,7 @@
        if (serviceId.value == '') { benefitLoading.value = false; benefitError.value = true; return }
        benefitLoading.value = true
        benefitError.value = false
        getChildrenEntrustmentBenefit(serviceId.value, (data: any) => {
        getChildrenEntrustmentBenefit(serviceId.value, talentId.value, (data: any) => {
            benefitTitle.value = data.title || '子女托管'
            applicableCards.value = data.applicableCards || '以服务说明为准'
            heroImage.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : ''
rencaika/pages/children-info-edit/children-info-edit.uvue
@@ -18,7 +18,7 @@
                <text class="field-label field-spacing">性别</text>
                <picker class="field-picker" :range="genders" :value="genderIndex" @change="changeGender">
                    <view class="field-box picker-box"><text>{{gender}}</text><text class="picker-arrow">⌄</text></view>
                    <view class="field-box picker-box"><text>{{gender}}</text><view class="picker-arrow"></view></view>
                </picker>
                <text class="field-label field-spacing">出生年月</text>
@@ -69,7 +69,7 @@
    const editMode = ref(false)
    const editId = ref(0)
    const talentId = ref('')
    const submitText = ref('提交修改')
    const submitText = ref('提交')
    const isSubmitting = ref(false)
    const formLoading = ref(false)
    const formError = ref(false)
@@ -89,7 +89,7 @@
        if (options != null && options['mode'] == 'edit') {
            editMode.value = true
            editId.value = parseInt(options['id'] as string)
            submitText.value = '重新提交'
            submitText.value = '提交'
            loadChild()
        }
        submitKey.value = createIdempotencyKey(editMode.value ? 'child-resubmit' : 'child-create')
@@ -241,7 +241,7 @@
    .field-box input { width:100%; height:88rpx; color:#475365; font-size:29rpx; line-height:40rpx; font-weight:600; }
    .field-picker { display:block; }
    .picker-box { color:#475365; font-size:29rpx; line-height:40rpx; font-weight:600; justify-content:space-between; }
    .picker-arrow { color:#8E9AAA; font-size:38rpx; line-height:42rpx; }
    .picker-arrow { width:14rpx; height:14rpx; margin-right:5rpx; border-right:3rpx solid #8E9AAA; border-bottom:3rpx solid #8E9AAA; transform:rotate(45deg) translateY(-3rpx); }
    .field-placeholder, .field-placeholder-text { color:#AAB3C0; font-size:28rpx; font-weight:400; }
    .proof-title { display:block; margin:49rpx 5rpx 0; color:#707C8D; font-size:27rpx; line-height:38rpx; font-weight:600; }
    .proof-note { display:block; margin:29rpx 10rpx 0; color:#7E8896; font-size:23rpx; line-height:35rpx; }
@@ -254,7 +254,7 @@
    .proof-delete { display:flex; width:39rpx; height:39rpx; position:absolute; z-index:2; right:-9rpx; top:-18rpx; border-radius:50%; background-color:#E26767; justify-content:center; align-items:center; }
    .proof-delete text { color:#FFFFFF; font-size:30rpx; line-height:34rpx; font-weight:300; }
    .proof-delete-active { opacity:.65; }
    .proof-add { display:flex; background-color:#F7F8FA; justify-content:center; align-items:center; }
    .proof-add { display:flex; box-sizing:border-box; border:2rpx dashed #B8C2CF; background-color:#FFFFFF; justify-content:center; align-items:center; }
    .proof-add image { width:48rpx; height:48rpx; }
    .proof-add-active { background-color:#EEF0F3; }
    .submit-button { display:flex; height:100rpx; margin:37rpx 2rpx 0; border-radius:52rpx; background-image:linear-gradient(90deg,#FF4B32 0%,#FF8A1E 100%); box-shadow:0 12rpx 25rpx rgba(255,105,41,.16); justify-content:center; align-items:center; }
rencaika/pages/merchant/merchant.uvue
@@ -514,7 +514,7 @@
            merchantAvailable.value = data != null && (data.available === true || Number(data.available) == 1)
            merchantUnavailableReason.value = data != null && data.unavailableReason != null && data.unavailableReason != '' ? data.unavailableReason as string : '该停车权益暂不可用'
            merchantServiceLabel.value = data != null && data.serviceLabel != null ? data.serviceLabel as string : '停车服务'
            parkingBenefitText.value = data != null && data.merchantRight.parkingType == 'free' ? '免费' : '付费'
            parkingBenefitText.value = data != null && data.parkingType == 'free' ? '免费' : '付费'
            applicableCards.value = data != null && data.applicableCards != null ? data.applicableCards as string : '请咨询商户'
            const parkingRightDetail = data != null && data.merchantRight != null && data.merchantRight.detail != null
                ? String(data.merchantRight.detail).trim()
rencaika/pages/premium-benefits/premium-benefits.uvue
@@ -50,7 +50,7 @@
    const loadServices = (append: boolean) => {
        if (!isApiConfigured() || activeTab.value == '') { initialLoading.value = false; loadingMore.value = false; initialError.value = true; return }; const requestVersion = listRequestVersion.value
        if (!append && failInitialOnce.value) { failInitialOnce.value = false; initialLoading.value = false; initialError.value = true; return }; if (append && failLoadMoreOnce.value) { failLoadMoreOnce.value = false; loadingMore.value = false; loadMoreError.value = true; return }
        getPremiumTopicServices(activeTab.value, currentPage.value + 1, 4, (data: any) => { if (requestVersion != listRequestVersion.value) return; const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []; for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: item.benefitText != null ? item.benefitText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }; visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
        getPremiumTopicServices(activeTab.value, currentPage.value + 1, 4, (data: any) => { if (requestVersion != listRequestVersion.value) return; const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []; for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: activeTab.value == '免费体检' || activeTab.value == '配偶推荐就业' ? '免费' : (item.benefitText != null ? item.benefitText as string : '人才专享'), address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }; visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
    }
    const loadMore = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadServices(true) }
    const goBack = () => safeBack('/pages/benefits/benefits')
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -52,8 +52,7 @@
                        v-for="item in visibleServices" :key="item.id" @tap="openService(item)">
                        <image class="service-image" :src="item.image" mode="aspectFill"
                            @error="replaceServiceImage(item)"></image>
                        <view class="service-info"><text class="service-name">{{item.name}}</text><text
                                class="discount-tag">{{item.available ? item.discount : '已下架'}}</text>
                        <view class="service-info"><text class="service-name">{{item.name}}</text><view v-if="item.available" class="discount-tags"><text v-for="tag in item.discountTags" :key="tag" class="discount-tag">{{tag}}</text></view><text v-else class="discount-tag">已下架</text>
                            <view v-if="item.merchantType != 'apartment'" class="address">
                                <image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text>
                            </view>
@@ -78,17 +77,20 @@
    import { getStayTopic, getStayTopicMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { safeBack } from '../../utils/navigation.uts'
    import { getSession } from '../../services/session.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const isSolidNav = ref(false); const windowWidth = ref(375); const fallbackImage = ''
    const activeIndex = ref(-1); const sceneScrollLeft = ref(0); const sceneTrackWidth = ref(644); const scenes = ref<any[]>([]); const currentScene = ref({ id: 'fallback', name: '', image: fallbackImage }); const topicDescription = ref('')
    const activeTab = ref('hotel'); const visibleServices = ref<any[]>([]); const initialLoading = ref(true); const initialError = ref(false); const loadingMore = ref(false); const loadMoreError = ref(false); const hasLoaded = ref(false); const hasMore = ref(false); const currentPage = ref(0); const listRequestVersion = ref(0); const failInitialOnce = ref(false); const failLoadMoreOnce = ref(false)
    const talentId = ref('')
    onLoad((options : any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); windowWidth.value = info.windowWidth; navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
        // #endif
        if (options != null && options['previewState'] != null) { const state = options['previewState'] as string; failInitialOnce.value = state == 'initialError'; failLoadMoreOnce.value = state == 'loadMoreError' }
        const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string
        loadStayTopic(); loadInitialServices()
    })
    const readText = (primary : any, fallback : any) : string => {
@@ -151,7 +153,7 @@
            loadMoreError.value = true
            return
        }
        getStayTopicMerchants(activeTab.value, currentPage.value + 1, 4, (data : any) => {
        getStayTopicMerchants(activeTab.value, currentPage.value + 1, 4, talentId.value, (data : any) => {
            if (requestVersion != listRequestVersion.value) return
            const records : any[] = data != null && data.records instanceof Array ? data.records as any[] : []
            const next = append ? visibleServices.value.slice() : []
@@ -167,6 +169,7 @@
                    merchantType: merchantType != '' ? merchantType : activeTab.value,
                    name: merchantName != '' ? merchantName : '\u4f4f\u5bbf\u670d\u52a1',
                    discount: discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab',
                    discountTags: item.benefitTags instanceof Array && (item.benefitTags as any[]).length > 0 ? item.benefitTags as any[] : [discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab'],
                    address: readText(item.address, item['merchant_address']),
                    image: coverUrl != '' ? resolveApiUrl(coverUrl) : fallbackImage,
                    available: readAvailable(item)
@@ -452,6 +455,7 @@
        font-size: 21rpx;
        line-height: 29rpx;
    }
    .discount-tags { display:flex; flex-direction:row; flex-wrap:wrap; gap:8rpx; }
    .address {
        display: flex;
        max-width: 100%;
rencaika/services/portal.uts
@@ -39,7 +39,11 @@
export const getTravelTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTravelTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getTravelTopicMerchants = (page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTravelTopicMerchants'), method: 'GET', skipAuth: true, data: { page: page, size: size } }, onSuccess, onError)
export const getStayTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appStayTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getStayTopicMerchants = (type: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appStayTopicMerchants'), method: 'GET', skipAuth: true, data: { type: type, page: page, size: size } }, onSuccess, onError)
export const getStayTopicMerchants = (type: string, page: number, size: number, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { type: type, page: page, size: size }
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appStayTopicMerchants'), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getEntertainmentTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appEntertainmentTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getEntertainmentTopicMerchants = (page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appEntertainmentTopicMerchants'), method: 'GET', skipAuth: true, data: { page: page, size: size } }, onSuccess, onError)
export const getTransportTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTransportTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
@@ -60,7 +64,11 @@
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appFinanceMerchantDetail') + encodeURIComponent(merchantId), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getChildrenEntrustmentBenefit = (serviceId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appChildrenEntrustmentBenefit') + encodeURIComponent(serviceId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getChildrenEntrustmentBenefit = (serviceId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = {}
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appChildrenEntrustmentBenefit') + encodeURIComponent(serviceId), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getChildrenEntrustmentSchools = (serviceId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appChildrenEntrustmentBenefit') + encodeURIComponent(serviceId) + '/schools', method: 'GET', skipAuth: true }, onSuccess, onError)
export const getChildrenEntrustmentSchool = (schoolId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appChildrenEntrustmentSchool') + encodeURIComponent(schoolId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getChildren = (talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appChildren'), method: 'GET', skipAuth: true, data: { talentId: talentId } }, onSuccess, onError)
talent-card-admin/src/views/MerchantCreateView.vue
@@ -781,17 +781,6 @@
          consumeDesc: '',
          consumeEnabled: false,
        },
        {
          level: '青年人才卡',
          enabled: true,
          tag: '',
          detail: '',
          storeConsume: false,
          discount: '',
          governmentSubsidy: false,
          consumeDesc: '',
          consumeEnabled: false,
        },
      ]
    }
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalController.java
@@ -7,6 +7,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -20,8 +21,9 @@
    }
    @GetMapping("/{serviceId}")
    public ApiResponse<Map<String, Object>> detail(@PathVariable Long serviceId) {
        return ApiResponse.success(service.detail(serviceId));
    public ApiResponse<Map<String, Object>> detail(@PathVariable Long serviceId,
                                                   @RequestParam(required = false) Long talentId) {
        return ApiResponse.success(service.detail(serviceId, talentId));
    }
    @GetMapping("/{serviceId}/schools")
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -21,17 +22,18 @@
    }
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long serviceId) {
        approvalService(serviceId);
    public Map<String, Object> detail(Long serviceId, Long talentId) {
        Map<String, Object> merchant = approvalService(serviceId);
        Map<String, Object> benefit = benefit();
        boolean available = number(benefit.get("enabled")) == 1;
        Map<String, Object> matchedRight = matchedRight(merchant.get("merchantRightsConfig"), activeCardLevel(talentId));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("benefitId", benefit.get("id"));
        result.put("serviceId", serviceId);
        result.put("title", benefit.get("title"));
        result.put("applicableCards", benefit.get("applicableCards"));
        result.put("heroImage", benefit.get("heroImage"));
        result.put("rightsText", benefit.get("rightsText"));
        result.put("rightsText", text(matchedRight.get("detail"), text(benefit.get("rightsText"), "暂无使用说明")));
        result.put("available", available);
        result.put("unavailableReason", available ? "" : text(benefit.get("unavailableReason"), "该权益暂未开放"));
        return result;
@@ -39,7 +41,9 @@
    @Transactional(readOnly = true)
    public List<Map<String, Object>> schools(Long serviceId) {
        approvalService(serviceId);
        Map<String, Object> merchant = approvalService(serviceId);
        List<Map<String, Object>> configuredSchools = configuredSchools(merchant.get("usageInstructions"));
        if (!configuredSchools.isEmpty()) return configuredSchools;
        Map<String, Object> benefit = benefit();
        return query("""
                select id, school_name, address, latitude, longitude, contact_mobile, detail_available
@@ -56,7 +60,29 @@
            school.put("phone", text(row.get("contactMobile"), ""));
            school.put("detailAvailable", number(row.get("detailAvailable")) == 1);
            return school;
        }).toList();
                }).toList();
    }
    private List<Map<String, Object>> configuredSchools(Object value) {
        String json = text(value, "");
        if (json.isBlank()) return List.of();
        try {
            List<Map<String, Object>> rows = JSON.parseObject(json, new com.alibaba.fastjson.TypeReference<>() {});
            if (rows == null) return List.of();
            return rows.stream().map(row -> {
                Map<String, Object> school = new LinkedHashMap<>();
                school.put("id", row.getOrDefault("id", row.get("name")));
                school.put("name", text(row.get("name"), "未命名学校"));
                school.put("address", text(row.get("address"), "暂未配置学校地址"));
                school.put("latitude", row.get("latitude"));
                school.put("longitude", row.get("longitude"));
                school.put("phone", text(row.get("phone"), text(row.get("contactMobile"), "")));
                school.put("detailAvailable", !text(row.get("detail"), "").isBlank());
                return school;
            }).toList();
        } catch (RuntimeException ignored) {
            return List.of();
        }
    }
    @Transactional(readOnly = true)
@@ -79,14 +105,48 @@
        return result;
    }
    private void approvalService(Long serviceId) {
    private Map<String, Object> approvalService(Long serviceId) {
        if (serviceId == null || serviceId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "学生托付服务信息不能为空");
        List<Map<String, Object>> rows = query("""
                select m.id
                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'
                """, serviceId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "学生托付服务不存在");
        return rows.get(0);
    }
    private Map<String, Object> matchedRight(Object value, String cardLevel) {
        if (cardLevel.isBlank() || value == null) return Map.of();
        try {
            List<Map<String, Object>> rights = JSON.parseObject(String.valueOf(value), new com.alibaba.fastjson.TypeReference<>() {});
            if (rights != null) {
                for (Map<String, Object> right : rights) {
                    if (booleanValue(right.get("enabled")) && cardLevel.equals(text(right.get("level"), ""))) return right;
                }
            }
        } catch (RuntimeException ignored) {
            // Fall back to the legacy children entrustment description when the configuration is invalid.
        }
        return Map.of();
    }
    private String activeCardLevel(Long talentId) {
        if (talentId == null || talentId <= 0) return "";
        List<Map<String, Object>> cards = query("""
                select card_level from talent_card
                where talent_id = ? and deleted = 0 and card_type = 'PERSONAL' and status = 1
                  and (valid_end is null or valid_end >= current_date)
                order by id desc limit 1
                """, talentId);
        return cards.isEmpty() ? "" : text(cards.get(0).get("cardLevel"), "");
    }
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
        String result = text(value, "");
        return "true".equalsIgnoreCase(result) || "1".equals(result) || "yes".equalsIgnoreCase(result);
    }
    private Map<String, Object> benefit() {
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java
@@ -52,13 +52,14 @@
        result.put("applicableCards", applicableCards(right, benefit));
        result.put("usageInstructions", text(merchant.get("usageInstructions"), "请咨询商户"));
        result.put("rightsText", rightsText(merchant, right));
        result.put("actionMode", text(benefit.get("actionMode"), "plate"));
        String parkingType = text(right == null ? null : right.get("parkingType"), "paid");
        result.put("actionMode", "free".equalsIgnoreCase(parkingType) ? "talentCode" : "plate");
        result.put("plateFormMode", text(benefit.get("plateFormMode"), "simple"));
        result.put("benefit", benefitView(benefit));
        result.put("applicableStores", parkingStores(merchant.get("usageInstructions")));
        result.put("merchantRightsConfig", rights);
        result.put("merchantRight", right == null ? Map.of() : right);
        result.put("parkingType", text(right == null ? null : right.get("parkingType"), "paid"));
        result.put("parkingType", parkingType);
        result.put("parkingScope", text(right == null ? null : right.get("parkingScope"), "all"));
        result.put("consumeDesc", text(right == null ? null : right.get("consumeDesc"), ""));
        return result;
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java
@@ -116,9 +116,10 @@
    public ApiResponse<PageResult<Map<String, Object>>> stayMerchants(
            @RequestParam(defaultValue = "hotel") String type,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "4") int size
            @RequestParam(defaultValue = "4") int size,
            @RequestParam(required = false) Long talentId
    ) {
        return ApiResponse.success(portalService.stayMerchants(type, page, size));
        return ApiResponse.success(portalService.stayMerchants(type, page, size, talentId));
    }
    @GetMapping("/topics/entertainment")
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -329,6 +329,11 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size) {
        return stayMerchants(type, page, size, null);
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size, Long talentId) {
        String requestedType = type == null ? "" : type.trim();
        boolean apartment = switch (requestedType) {
            case "hotel", "酒店服务" -> false;
@@ -341,7 +346,7 @@
                : " where deleted = 0 and merchant_type in ('hotel', '酒店服务')";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, 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, 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
                from merchant
@@ -350,9 +355,29 @@
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> normalized = new LinkedHashMap<>(row);
            normalized.put("merchantType", responseMerchantType);
            String cardLevel = activeProductCardLevel(talentId);
            Map<String, Object> right = matchingMerchantRight(merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, activeTalentSourceAreaType(talentId), true);
            normalized.put("benefitTags", stayBenefitTags(right, row));
            return enrichMerchantSearchRow(normalized);
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }
    private List<String> stayBenefitTags(Map<String, Object> right, Map<String, Object> merchant) {
        List<String> tags = new ArrayList<>();
        Object merchantId = merchant.get("id");
        if (merchantId instanceof Number id && hasActiveMerchantCoupon(id.longValue())) tags.add("惠");
        if (right == null) return tags;
        if (booleanValue(right.get("freeVoucherEnabled"))) {
            String times = textOrDefault(right.get("freeVoucherTimes"), "");
            if (!times.isBlank()) tags.add("免费" + times + "次");
        }
        if (booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
            String type = textOrDefault(right.get("consumeType"), "");
            String discount = textOrDefault(right.get("consumeDiscount"), "");
            if ("折扣".equals(type) && !discount.isBlank()) tags.add(discount + "折");
        }
        return tags;
    }
    @Transactional(readOnly = true)
@@ -471,18 +496,26 @@
                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 -> "('')";
        };
        String merchantWhere = """
                where m.deleted = 0 and m.merchant_type = 'shopping'
                  and mapping.deleted = 0 and mapping.category_key = ?
                """;
                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);
        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,
                       m.service_label, m.status, m.sort_no, m.longitude, m.latitude, m.business_status
                from merchant m
                join shopping_topic_merchant mapping on mapping.merchant_id = m.id
                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 join shopping_topic_merchant mapping on mapping.merchant_id = m.id " + merchantWhere,
                page, size, categoryId);
                "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);
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("discountText", textOrDefault(item.get("serviceLabel"), "人才专享"));
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
@@ -22,19 +22,23 @@
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long merchantId) {
        Map<String, Object> row = one("""
                select m.id, m.merchant_name, m.cover_url, m.service_label, m.address, m.contact_mobile,
                       m.longitude, m.latitude, m.status as merchant_status, mapping.category_key,
                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,
                       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
                join shopping_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0
                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 = 'shopping' and m.deleted = 0
                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
                order by detail_config.id asc limit 1
                """, merchantId);
        boolean available = number(row.get("merchantStatus")) == 1 && number(row.get("detailStatus")) == 1;
        boolean available = number(row.get("merchantStatus")) == 1 && (row.get("detailStatus") == null || number(row.get("detailStatus")) == 1);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("merchantId", row.get("id"));
        result.put("categoryId", row.get("categoryKey"));
@@ -46,16 +50,16 @@
        result.put("contactMobile", text(row.get("contactMobile"), ""));
        result.put("longitude", row.get("longitude"));
        result.put("latitude", row.get("latitude"));
        result.put("benefitDetail", text(row.get("benefitDetail"), "以服务专员说明为准"));
        result.put("benefitDetail", text(row.get("benefitDetail"), text(row.get("rightsText"), "以服务专员说明为准")));
        result.put("available", available);
        result.put("unavailableReason", available ? "" : text(row.get("unavailableReason"), "该购物服务当前不可用"));
        Map<String, Object> advisor = new LinkedHashMap<>();
        advisor.put("advisorId", row.get("advisorId"));
        advisor.put("name", text(row.get("advisorName"), ""));
        advisor.put("name", text(row.get("advisorName"), text(row.get("contactName"), "")));
        advisor.put("department", text(row.get("advisorDepartment"), ""));
        advisor.put("mobile", text(row.get("advisorMobile"), ""));
        advisor.put("mobile", text(row.get("advisorMobile"), text(row.get("contactMobile"), "")));
        advisor.put("serviceTime", text(row.get("advisorServiceTime"), ""));
        advisor.put("available", number(row.get("advisorAvailable")) == 1 && !text(row.get("advisorMobile"), "").isEmpty());
        advisor.put("available", (row.get("advisorAvailable") == null || number(row.get("advisorAvailable")) == 1) && !text(row.get("advisorMobile"), text(row.get("contactMobile"), "")).isEmpty());
        result.put("advisor", advisor);
        return result;
    }