shinnosuke
2026-08-21 348ea7313f2f6681de2b0693e53c59edaf202bd1
fix: complete miniapp benefit and coupon flows
1个文件已添加
12个文件已修改
222 ■■■■■ 已修改文件
rencaika/docs/pages/51-行政审批.md 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/admin-approval/admin-approval.uvue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/coupon-received/coupon-received.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/shopping-benefits/shopping-benefits.uvue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/ticket-package/ticket-package.uvue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V120__add_telecom_and_dental_shopping_categories.sql 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/51-行政审批.md
@@ -6,7 +6,7 @@
- 路由:`/pages/admin-approval/admin-approval`
- 源码:`pages/admin-approval/admin-approval.uvue`
- 入口:臻享礼遇页“行政审批”分类点击服务项进入。
- 当前状态:视觉与本地交互首版已完成,真实机构列表、咨询电话与窗口信息接口待接入。
- 当前状态:已接入行政审批详情接口;窗口信息直接打开后台上传的 PDF 文件。
## 功能点
@@ -16,14 +16,14 @@
| 权益标识 | 标题“行政审批”与“免费”标签固定展示。 | 后台返回权益名称与免费/付费标识。 |
| 机构列表 | 展示机构名称、地址、权益内容说明和咨询电话,内容超出时区内滚动;已补加载、空、失败和重试。 | 后台返回机构 ID、名称、地址、经纬度、纯文本权益说明、联系电话、排序和启用状态。 |
| 咨询电话 | 点击“咨询电话-号码”直接调起系统拨号。 | 号码由后台返回,为空时隐藏电话入口。 |
| 窗口信息 | 有窗口信息的机构展示“查看窗口信息”按钮,点击弹出窗口与办公时间说明。 | 后台返回窗口数组(窗口名称、楼层位置)、办公时间和是否展示入口。 |
| 窗口信息 | 有 PDF 文件的机构展示“查看窗口信息”按钮,点击下载并打开 PDF 阅读器。 | 后台返回 `windowInfoUrl`(后台上传 PDF 的 URL);没有文件时隐藏入口。 |
## 操作说明
1. 用户从臻享礼遇页选择“行政审批”分类,点击任意服务项进入本页。
2. 浏览各政务服务机构的地址和权益内容,机构较多时上滑浏览。
3. 点击“咨询电话-号码”调起系统拨号。
4. 点击“查看窗口信息”查看办理窗口位置和办公时间,点击遮罩或“我知道了”关闭。
4. 点击“查看窗口信息”下载并打开后台上传的窗口信息 PDF。
5. 点击左上角返回按钮回到臻享礼遇页。
## 异常处理
@@ -40,4 +40,4 @@
- 权益信息:权益 ID、名称、免费/付费标识、头图 URL、启用状态。
- 机构列表:机构 ID、名称、地址、经纬度、纯文本权益说明、联系电话、排序、启用状态。
- 窗口信息:机构 ID、窗口数组(窗口名称、所在楼层/位置)、办公时间、是否展示窗口入口。
- 窗口信息:机构 ID、`windowInfoUrl`(PDF 地址)、是否展示窗口入口。
rencaika/pages/admin-approval/admin-approval.uvue
@@ -24,22 +24,13 @@
                    <view class="org-desc"><text>{{item.description}}</text></view>
                    <view class="org-footer">
                        <view v-if="item.phone != ''" class="phone-row" @tap="callOrg(item)"><image src="/static/premium-assets/phone.png" mode="aspectFit"></image><text>咨询电话-{{item.phone}}</text></view>
                        <view v-if="item.hasWindowInfo" class="window-button" hover-class="window-button-active" @tap="showWindowInfo(item)"><text>查看窗口信息</text></view>
                        <view v-if="item.hasWindowInfo" class="window-button" hover-class="window-button-active" @tap="openWindowInfo(item)"><text>查看窗口信息</text></view>
                    </view>
                </view>
                <view class="bottom-space"></view>
            </scroll-view>
        </view>
        <view v-if="popupVisible" class="popup-mask" @tap="hideWindowInfo">
            <view class="popup-card" @tap.stop="noop">
                <text class="popup-title">窗口信息</text>
                <text class="popup-org">{{popupOrgName}}</text>
                <view v-for="window in popupWindows" :key="window.id" class="popup-row"><view class="popup-dot"></view><text>{{window.name}}{{window.location != '' ? '(' + window.location + ')' : ''}}</text></view>
                <text class="popup-time">办公时间:{{popupHours}}</text>
                <view class="popup-close" hover-class="popup-close-active" @tap="hideWindowInfo"><text>我知道了</text></view>
            </view>
        </view>
    </view>
</template>
@@ -62,10 +53,6 @@
    const listLoading = ref(true)
    const listLoaded = ref(false)
    const listError = ref(false)
    const popupVisible = ref(false)
    const popupOrgName = ref('')
    const popupHours = ref('')
    const popupWindows = ref<any[]>([])
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -96,17 +83,34 @@
        }, () => { listLoading.value = false; listError.value = true })
    }
    const goBack = () => safeBack('/pages/premium-benefits/premium-benefits')
    const noop = () => {}
    const replaceHero = () => { heroImage.value = '' }
    const callOrg = (item: any) => { if (item.phone == null || item.phone == '') { uni.showToast({ title: '机构暂未提供咨询电话', icon: 'none' }); return }; uni.makePhoneCall({ phoneNumber: item.phone, fail: () => uni.showToast({ title: '暂时无法拨打电话', icon: 'none' }) }) }
    const showWindowInfo = (item: any) => {
        if (!(item.windows instanceof Array) || item.windows.length == 0) return
        popupOrgName.value = item.name || ''
        popupHours.value = item.officeHours || '以机构公示时间为准'
        popupWindows.value = item.windows as any[]
        popupVisible.value = true
    const openWindowInfo = (item: any) => {
        const source = item != null && item.windowInfoUrl != null ? String(item.windowInfoUrl) : ''
        if (source == '') {
            uni.showToast({ title: '窗口信息文件暂未配置', icon: 'none' })
            return
        }
        const url = resolveApiUrl(source)
        uni.showLoading({ title: '正在打开文件', mask: true })
        uni.downloadFile({
            url: url,
            success: (result: any) => {
                if (result.statusCode != 200 || result.tempFilePath == null || result.tempFilePath == '') {
                    uni.showToast({ title: '窗口信息文件加载失败', icon: 'none' })
                    return
                }
                uni.openDocument({
                    filePath: result.tempFilePath as string,
                    fileType: 'pdf',
                    showMenu: true,
                    fail: () => uni.showToast({ title: '暂时无法打开 PDF 文件', icon: 'none' })
                })
            },
            fail: () => uni.showToast({ title: '窗口信息文件加载失败', icon: 'none' }),
            complete: () => uni.hideLoading()
        })
    }
    const hideWindowInfo = () => { popupVisible.value = false }
</script>
<style>
@@ -148,15 +152,4 @@
    .window-button text { color:#FF7A27; font-size:24rpx; line-height:32rpx; }
    .window-button-active { opacity:.7; }
    .bottom-space { height:calc(30rpx + env(safe-area-inset-bottom)); }
    .popup-mask { display:flex; position:fixed; z-index:30; left:0; right:0; top:0; bottom:0; background-color:rgba(0,0,0,.5); justify-content:center; align-items:center; }
    .popup-card { display:flex; width:600rpx; box-sizing:border-box; padding:40rpx; border-radius:24rpx; background-color:#FFFFFF; flex-direction:column; }
    .popup-title { color:#252525; font-size:32rpx; line-height:44rpx; font-weight:700; text-align:center; }
    .popup-org { margin-top:24rpx; color:#363636; font-size:27rpx; line-height:38rpx; font-weight:600; }
    .popup-row { display:flex; margin-top:14rpx; flex-direction:row; align-items:center; }
    .popup-dot { width:10rpx; height:10rpx; margin-right:12rpx; border-radius:50%; background-color:#FF7A27; flex-shrink:0; }
    .popup-row text { color:#5A5F66; font-size:25rpx; line-height:36rpx; }
    .popup-time { margin-top:20rpx; color:#8A8D92; font-size:23rpx; line-height:32rpx; }
    .popup-close { display:flex; height:76rpx; margin-top:30rpx; border-radius:38rpx; background-image:linear-gradient(90deg,#FF4B32,#FF8A1E); justify-content:center; align-items:center; }
    .popup-close text { color:#FFFFFF; font-size:28rpx; font-weight:600; }
    .popup-close-active { opacity:.8; }
</style>
rencaika/pages/coupon-received/coupon-received.uvue
@@ -17,14 +17,20 @@
    import { getCinemaCouponClaim, getTicketPackageClaim } from '../../services/portal.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    // Keep the first render below the status bar even before the MP capsule metrics arrive.
    const navTop = ref(44); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const claimId = ref(''); const merchantId = ref(''); const talentId = ref(''); const claimType = ref('cinemaCoupon'); const loading = ref(true); const loadError = ref(false)
    const couponName = ref('电影券'); const merchantName = ref(''); const annualTotal = ref(0); const annualClaimed = ref(0); const annualRemaining = ref(0); const hasAnnual = ref(false)
    const exchangeCode = ref(''); const exchangeCodeStatus = ref(''); const codeStatusText = ref('兑换码生成中,请稍后重试'); const voucherTip = ref('复制后前往影城小程序使用,核销时出示兑换码')
    onLoad((options: any) => { if (options != null && options['claimId'] != null) claimId.value = String(options['claimId']); if (options != null && options['merchantId'] != null) merchantId.value = String(options['merchantId']); if (options != null && options['type'] != null) claimType.value = String(options['type']); if (options != null && options['name'] != null) couponName.value = decodeURIComponent(String(options['name'])); const session = getSession(); if (session != null && session.talentId != null) talentId.value = String(session.talentId); if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'; loadClaim() })
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect(); const systemInfo = uni.getSystemInfoSync(); navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = systemInfo.windowWidth - capsule.right
        // #endif
        if (options != null && options['claimId'] != null) claimId.value = String(options['claimId']); if (options != null && options['merchantId'] != null) merchantId.value = String(options['merchantId']); if (options != null && options['type'] != null) claimType.value = String(options['type']); if (options != null && options['name'] != null) couponName.value = decodeURIComponent(String(options['name'])); const session = getSession(); if (session != null && session.talentId != null) talentId.value = String(session.talentId); if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'; loadClaim()
    })
    const progressPercent = computed(() => annualTotal.value > 0 ? Math.min(100, Math.round(annualClaimed.value * 100 / annualTotal.value)) : 0)
    const canCopy = computed(() => exchangeCodeStatus.value == 'ACTIVE' && exchangeCode.value != '')
    const loadClaim = () => { if (claimId.value == '' || talentId.value == '') { loading.value = false; loadError.value = true; return }; loading.value = true; loadError.value = false; const onSuccess = (data: any) => { loading.value = false; couponName.value = data.couponName != null ? String(data.couponName) : couponName.value; merchantName.value = data.merchantName != null ? String(data.merchantName) : ''; const totalValue = data.packageQuotaTotal != null ? data.packageQuotaTotal : (data.quotaTotal != null ? data.quotaTotal : data.annualTotal); const usedValue = data.packageQuotaUsed != null ? data.packageQuotaUsed : (data.quotaUsed != null ? data.quotaUsed : data.annualClaimed); const remainingValue = data.packageQuotaRemaining != null ? data.packageQuotaRemaining : (data.quotaRemaining != null ? data.quotaRemaining : data.annualRemaining); if (totalValue != null && usedValue != null && remainingValue != null) { annualTotal.value = Number(totalValue); annualClaimed.value = Number(usedValue); annualRemaining.value = Number(remainingValue); hasAnnual.value = annualTotal.value > 0 }; exchangeCode.value = data.exchangeCode != null ? String(data.exchangeCode) : ''; exchangeCodeStatus.value = data.exchangeCodeStatus != null ? String(data.exchangeCodeStatus) : ''; if (exchangeCodeStatus.value == 'USED') codeStatusText.value = '兑换码已使用'; else if (exchangeCodeStatus.value == 'EXPIRED') codeStatusText.value = '兑换码已失效'; else if (exchangeCode.value == '') codeStatusText.value = '兑换码生成中,请稍后重试'; else codeStatusText.value = ''; const channel = data.appName != null && String(data.appName) != '' ? String(data.appName) + 'APP' : (merchantName.value == '' ? '影城' : merchantName.value) + '小程序'; voucherTip.value = '复制后前往' + channel + '使用,核销时出示兑换码' }; const onError = () => { loading.value = false; loadError.value = true }; if (claimType.value == 'cinemaCoupon') getCinemaCouponClaim(claimId.value, talentId.value, onSuccess, onError); else getTicketPackageClaim(claimId.value, talentId.value, onSuccess, onError) }
    const loadClaim = () => { if (claimId.value == '' || talentId.value == '') { loading.value = false; loadError.value = true; return }; loading.value = true; loadError.value = false; const onSuccess = (data: any) => { loading.value = false; couponName.value = data.couponName != null ? String(data.couponName) : couponName.value; merchantName.value = data.merchantName != null ? String(data.merchantName) : ''; const totalValue = data.packageQuotaTotal != null ? data.packageQuotaTotal : (data.quotaTotal != null ? data.quotaTotal : data.annualTotal); const usedValue = data.packageQuotaUsed != null ? data.packageQuotaUsed : (data.quotaUsed != null ? data.quotaUsed : data.annualClaimed); const remainingValue = data.packageQuotaRemaining != null ? data.packageQuotaRemaining : (data.quotaRemaining != null ? data.quotaRemaining : data.annualRemaining); if (totalValue != null && usedValue != null && remainingValue != null) { annualTotal.value = Number(totalValue); annualClaimed.value = Number(usedValue); annualRemaining.value = Number(remainingValue); hasAnnual.value = annualTotal.value > 0 }; exchangeCode.value = data.exchangeCode != null ? String(data.exchangeCode) : ''; exchangeCodeStatus.value = data.exchangeCodeStatus != null ? String(data.exchangeCodeStatus) : ''; if (exchangeCodeStatus.value == 'USED') codeStatusText.value = '兑换码已使用'; else if (exchangeCodeStatus.value == 'EXPIRED') codeStatusText.value = '兑换码已失效'; else if (exchangeCodeStatus.value == 'LEGACY' || exchangeCodeStatus.value == 'UNAVAILABLE') codeStatusText.value = '历史电影券暂无兑换码'; else if (exchangeCode.value == '') codeStatusText.value = '兑换码生成中,请稍后重试'; else codeStatusText.value = ''; const channel = data.appName != null && String(data.appName) != '' ? String(data.appName) + 'APP' : (merchantName.value == '' ? '影城' : merchantName.value) + '小程序'; voucherTip.value = exchangeCode.value == '' ? '该电影券为历史领取记录,当前无需操作' : '复制后前往' + channel + '使用,核销时出示兑换码' }; const onError = () => { loading.value = false; loadError.value = true }; if (claimType.value == 'cinemaCoupon') getCinemaCouponClaim(claimId.value, talentId.value, onSuccess, onError); else getTicketPackageClaim(claimId.value, talentId.value, onSuccess, onError) }
    const goBack = () => safeBack('/pages/cinema-merchant/cinema-merchant?merchantId=' + encodeURIComponent(merchantId.value))
    const copyExchangeCode = () => { if (!canCopy.value) return; uni.setClipboardData({ data: exchangeCode.value, success: () => uni.showToast({ title: '兑换码已复制', icon: 'success' }), fail: () => uni.showToast({ title: '复制失败,请重试', icon: 'none' }) }) }
</script>
rencaika/pages/merchant/merchant.uvue
@@ -587,7 +587,11 @@
            usageInstructions.value = data != null && data.usageInstructions != null && data.usageInstructions != '' ? data.usageInstructions as string : usageInstructions.value
            if (data != null && data.cardLevel != null && data.cardLevel != '') currentCardLevel.value = data.cardLevel as string
            applyMedicalRightsConfig(data)
            groupBuyingEnabled.value = data == null || data.groupBuyingEnabled == null ? groupBuyingEnabled.value : data.groupBuyingEnabled === true || Number(data.groupBuyingEnabled) == 1
            // 普通自助洗车复用餐饮商家的团单展示;外链洗车不会进入此通用详情页。
            const isNormalCarWash = merchantType.value == 'carWash'
            groupBuyingEnabled.value = isNormalCarWash
                ? true
                : (data == null || data.groupBuyingEnabled == null ? groupBuyingEnabled.value : data.groupBuyingEnabled === true || Number(data.groupBuyingEnabled) == 1)
            isHotelMerchant.value = merchantType.value == 'hotel'
            hasFreeVoucher.value = isHotelMerchant.value && data != null && data.rightsStatus != null && String(data.rightsStatus) == 'AVAILABLE' && (data.freeVoucherEnabled === true || Number(data.freeVoucherEnabled) == 1)
            if (hasFreeVoucher.value) {
rencaika/pages/shopping-benefits/shopping-benefits.uvue
@@ -70,14 +70,21 @@
        getShoppingTopicMerchants(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, name: item.merchantName != null ? item.merchantName as string : '购物服务', discount: item.discountText != null ? item.discountText 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 }) }
            for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : '', name: item.merchantName != null ? item.merchantName as string : '购物服务', discount: item.discountText != null ? item.discountText 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 openDisplayPolicies = () => uni.navigateTo({ url: '/pages/display-policy/display-policy' })
    const goBack = () => safeBack('/pages/benefits/benefits')
    const openMerchant = (item: any) => { if (!item.available) { uni.showToast({ title: '该服务已停用', icon: 'none' }); return }; uni.navigateTo({ url: '/pages/shopping-merchant/shopping-merchant?merchantId=' + encodeURIComponent(item.id) }) }
    const openMerchant = (item: any) => {
        if (!item.available) { uni.showToast({ title: '该服务已停用', icon: 'none' }); return }
        if (item.merchantType == 'dental') {
            uni.navigateTo({ url: '/pages/merchant/merchant?merchantId=' + encodeURIComponent(item.id) + '&merchantType=dental&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) })
            return
        }
        uni.navigateTo({ url: '/pages/shopping-merchant/shopping-merchant?merchantId=' + encodeURIComponent(item.id) })
    }
</script>
<style>
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -164,12 +164,16 @@
                const merchantType = readText(item.merchantType, item['merchant_type'])
                const merchantName = readText(item.merchantName, item['merchant_name'])
                const discountText = readText(item.discountText, item['discount_text'])
                const configuredTags: any[] = item.benefitTags instanceof Array ? (item.benefitTags as any[]) : []
                const displayTags: any[] = activeTab.value == 'hotel'
                    ? configuredTags.filter((tag: any) => String(tag).trim() != '惠')
                    : configuredTags
                next.push({
                    id: '' + item.id,
                    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'],
                    discountTags: displayTags.length > 0 ? displayTags : [discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab'],
                    address: readText(item.address, item['merchant_address']),
                    image: coverUrl != '' ? resolveApiUrl(coverUrl) : fallbackImage,
                    available: readAvailable(item)
rencaika/pages/ticket-package/ticket-package.uvue
@@ -53,7 +53,8 @@
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
    // Keep the first render below the status bar even before the MP capsule metrics arrive.
    const navTop = ref(44)
    const navHeight = ref(32)
    const navWidth = ref(88)
    const navRight = ref(10)
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java
@@ -52,9 +52,9 @@
                        "数码产品服务"),
                type("culture", "文化服务", "惠享好物", "SHOPPING", null, null, "book", true,
                        "图书服务", "文化图书服务"),
                type("telecom", "电信服务", "惠享好物", "SHOPPING", null, null, "digital", true,
                type("telecom", "电信服务", "惠享好物", "SHOPPING", null, null, "telecom", true,
                        "通信服务"),
                type("dental", "口腔治疗保健", "臻享礼遇", "MERCHANT", null, null, null, true,
                type("dental", "口腔治疗保健", "惠享好物", "MERCHANT", null, null, "dental", true,
                        "口腔服务", "口腔保健"),
                type("shopping", "购物服务", "惠享好物", "SHOPPING", null, null, null, true,
                        "惠享好物")
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
@@ -10,9 +10,14 @@
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Service
public class AdminApprovalPortalService {
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private static final TypeReference<List<Map<String, Object>>> APPROVAL_ITEMS = new TypeReference<>() {};
    private final JdbcTemplate jdbc;
@@ -22,23 +27,68 @@
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long serviceId) {
        approvalService(serviceId);
        Map<String, Object> merchant = approvalService(serviceId);
        Map<String, Object> benefit = benefit();
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("benefitId", benefit.get("id"));
        result.put("serviceId", serviceId);
        result.put("title", benefit.get("title"));
        result.put("freeLabel", benefit.get("freeLabel"));
        result.put("heroImage", benefit.get("heroImage"));
        result.put("available", number(benefit.get("enabled")) == 1);
        result.put("heroImage", text(merchant.get("coverUrl"), text(benefit.get("heroImage"))));
        result.put("available", number(benefit.get("enabled")) == 1 && number(merchant.get("status")) == 1);
        result.put("unavailableReason", text(benefit.get("unavailableReason"), "该权益暂未开放"));
        result.put("organizations", organizations(((Number) benefit.get("id")).longValue()));
        List<Map<String, Object>> configuredOrganizations = configuredOrganizations(merchant);
        result.put("organizations", configuredOrganizations.isEmpty()
                ? organizations(((Number) benefit.get("id")).longValue())
                : configuredOrganizations);
        return result;
    }
    private List<Map<String, Object>> configuredOrganizations(Map<String, Object> merchant) {
        List<Map<String, Object>> items = parseApprovalItems(merchant.get("usageInstructions"));
        if (items.isEmpty()) {
            String rightsText = text(merchant.get("rightsText"));
            if (rightsText.isBlank()) return List.of();
            items = List.of(Map.of(
                    "name", text(merchant.get("merchantName"), "行政审批服务"),
                    "content", rightsText,
                    "address", text(merchant.get("address")),
                    "phone", text(merchant.get("contactMobile"))));
        }
        List<Map<String, Object>> result = new java.util.ArrayList<>();
        for (int index = 0; index < items.size(); index++) {
            Map<String, Object> item = items.get(index);
            Map<String, Object> organization = new LinkedHashMap<>();
            organization.put("id", merchant.get("id") + "-" + index);
            organization.put("name", text(item.get("name"), text(merchant.get("merchantName"), "行政审批服务")));
            organization.put("address", text(item.get("address"), text(merchant.get("address"))));
            organization.put("latitude", merchant.get("latitude"));
            organization.put("longitude", merchant.get("longitude"));
            organization.put("description", text(item.get("content"), text(merchant.get("rightsText"), "暂无权益说明")));
            organization.put("phone", text(item.get("phone"), text(merchant.get("contactMobile"))));
            organization.put("officeHours", "");
            String windowInfoUrl = text(item.get("windowInfoUrl"), text(item.get("pdfFile"), text(item.get("pdf_file"))));
            organization.put("windowInfoUrl", windowInfoUrl);
            organization.put("windows", List.of());
            organization.put("hasWindowInfo", !windowInfoUrl.isBlank());
            result.add(organization);
        }
        return result;
    }
    private List<Map<String, Object>> parseApprovalItems(Object value) {
        if (value == null || String.valueOf(value).isBlank()) return List.of();
        try {
            return OBJECT_MAPPER.readValue(String.valueOf(value), APPROVAL_ITEMS);
        } catch (Exception ignored) {
            return List.of();
        }
    }
    private List<Map<String, Object>> organizations(Long benefitId) {
        return query("""
                select id, organization_name, address, latitude, longitude, rights_text, consultation_phone, office_hours
                select id, organization_name, address, latitude, longitude, rights_text, consultation_phone, office_hours,
                       pdf_file, is_pdf_visible
                from admin_approval_organization
                where benefit_id = ? and enabled = 1 and deleted = 0
                order by sort_no asc, id asc
@@ -61,8 +111,10 @@
            organization.put("description", row.get("rightsText"));
            organization.put("phone", text(row.get("consultationPhone")));
            organization.put("officeHours", text(row.get("officeHours")));
            String windowInfoUrl = number(row.get("isPdfVisible")) == 1 ? text(row.get("pdfFile")) : "";
            organization.put("windowInfoUrl", windowInfoUrl);
            organization.put("windows", windows);
            organization.put("hasWindowInfo", !windows.isEmpty());
            organization.put("hasWindowInfo", !windowInfoUrl.isBlank());
            return organization;
        }).toList();
    }
@@ -70,7 +122,8 @@
    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_name, m.address, m.contact_mobile, m.cover_url, m.latitude, m.longitude,
                       m.status, m.usage_instructions, m.rights_text
                from merchant m
                where m.id = ? and m.deleted = 0 and m.merchant_type_code = 'approval'
                """, serviceId);
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
@@ -136,7 +136,7 @@
                join merchant m on m.id = cc.merchant_id and m.deleted = 0
                where cc.id = ? and cc.talent_id = ? and cc.deleted = 0
                """, claimId, talentId);
        if (claims.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "用户券不存在或无权查看");
        if (claims.isEmpty()) return legacyClaimDetail(claimId, talentId);
        Map<String, Object> claim = claims.get(0);
        int annualTotal = number(claim.get("perUserLimit"));
        if (annualTotal < 1) annualTotal = number(claim.get("annualQuota"));
@@ -168,6 +168,33 @@
        return result;
    }
    private Map<String, Object> legacyClaimDetail(Long recordId, Long talentId) {
        List<Map<String, Object>> records = query("""
                select r.id, r.receive_no, r.package_name, r.merchants
                from movie_ticket_record r
                where r.id = ? and r.deleted = 0
                  and exists (
                      select 1 from talent_card c
                      where c.talent_id = ? and c.deleted = 0 and c.card_type = 'PERSONAL' and c.card_no = r.card_no
                  )
                """, recordId, talentId);
        if (records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "用户券不存在或无权查看");
        Map<String, Object> record = records.get(0);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("claimId", record.get("id"));
        result.put("couponName", text(record.get("packageName"), "电影券"));
        result.put("merchantName", text(record.get("merchants"), "影城"));
        result.put("exchangeCodeStatus", "LEGACY");
        result.put("exchangeCode", "");
        result.put("annualTotal", 0);
        result.put("annualClaimed", 0);
        result.put("annualRemaining", 0);
        result.put("channel", "影城小程序");
        result.put("verificationRule", "历史电影券领取记录");
        result.put("invalidReason", "历史记录");
        return result;
    }
    private Map<String, Object> ticketPackageCouponView(Map<String, Object> row) {
        int quotaTotal = number(value(row, "packageQuotaTotal", "quotaTotal"));
        int quotaUsed = number(value(row, "packageQuotaUsed", "quotaUsed"));
talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
@@ -124,7 +124,7 @@
                select concat('movie-', r.id) as record_id,
                       'MOVIE_TICKET' as record_type,
                       r.receive_no as business_id,
                       cc.id as target_detail_id,
                       coalesce(cc.id, r.id) as target_detail_id,
                       null as merchant_id,
                       '万达影城' as merchant_name,
                       'cinema' as merchant_type,
talent-card-server/src/main/resources/db/migration/V120__add_telecom_and_dental_shopping_categories.sql
New file
@@ -0,0 +1,11 @@
insert into shopping_topic_category(category_key, category_name, sort_no, enabled)
select 'telecom', '电信服务', 60, 1
where not exists (
    select 1 from shopping_topic_category where category_key = 'telecom'
);
insert into shopping_topic_category(category_key, category_name, sort_no, enabled)
select 'dental', '口腔治疗保健', 70, 1
where not exists (
    select 1 from shopping_topic_category where category_key = 'dental'
);
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java
@@ -26,6 +26,10 @@
        assertThat(medical.premiumBusinessType()).isEqualTo("MEDICAL");
        assertThat(MerchantTypeCatalog.codesForShoppingCategory("book")).containsExactly("culture");
        assertThat(MerchantTypeCatalog.codesForShoppingCategory("telecom")).containsExactly("telecom");
        assertThat(MerchantTypeCatalog.codesForShoppingCategory("dental")).containsExactly("dental");
        assertThat(MerchantTypeCatalog.find("口腔治疗保健").orElseThrow().topicName()).isEqualTo("惠享好物");
        assertThat(MerchantTypeCatalog.find("口腔治疗保健").orElseThrow().detailType()).isEqualTo("MERCHANT");
        assertThat(MerchantTypeCatalog.find("酒店服务").orElseThrow().supportsMerchantAppLogin()).isTrue();
    }