wenminjie
2026-08-06 8898f00c2150048a03ca561b7ed98dbf9c80732b
flyway、对接小程序
1个文件已删除
1个文件已添加
18个文件已修改
874 ■■■■■ 已修改文件
rencaika/config/api.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages.json 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/cinema-merchant/cinema-merchant.uvue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/coupon-received/coupon-received.uvue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/free-benefit/free-benefit.uvue 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/shopping-benefits/shopping-benefits.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V93__merchant_onboarding_huifu_kyc.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/config/api.uts
@@ -70,7 +70,10 @@
    if (name == 'appCancelledOrders') return '/api/app/cancelled-orders/'
    if (name == 'appProcessingApplications') return '/api/app/processing-applications/'
    if (name == 'appFamilyCards') return '/api/app/family-cards/applications'
    if (name == 'appTicketPackages') return '/api/app/ticket-packages'
    if (name == 'appDisplayMerchantPolicies') return '/api/app/display-merchant-policies'
    if (name == 'appFreeBenefits') return '/api/app/free-benefits'
    if (name == 'appBenefitVerificationCodes') return '/api/app/benefit-verification-codes'
    if (name == 'appAgreements') return '/api/app/agreements/'
    if (name == 'appUploadImage') return '/api/app/uploads/images'
    if (name == 'authLogin') return '/api/app/auth/login'
rencaika/pages.json
@@ -461,6 +461,20 @@
                "navigationStyle": "custom",
                "backgroundColor": "#FFFFFF"
            }
        },
        {
            "path": "pages/ticket-package/ticket-package",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F5F7FA"
            }
        },
        {
            "path": "pages/display-policy/display-policy",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F5F7FA"
            }
        }
    ],
    "globalStyle": {
rencaika/pages/cinema-merchant/cinema-merchant.uvue
@@ -9,7 +9,7 @@
                <view v-else class="merchant-detail">
                    <view class="merchant-info"><view class="title-row"><text class="merchant-name">{{cinemaName}}</text><view class="coupon-badge"><text>券</text></view></view><view class="service-tag"><text>{{talentCardTag}}</text></view><view class="address-row"><image class="address-icon" src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text class="address-text">{{cinemaAddress}}</text><view class="merchant-actions"><view class="action-button" @tap="openMap"><image src="/static/merchant-assets/navigation.svg" mode="aspectFit"></image></view><view v-if="cinemaMobile != ''" class="action-button" @tap="callCinema"><image src="/static/merchant-assets/phone.svg" mode="aspectFit"></image></view></view></view></view>
                    <view class="tabs"><view class="tab-item" :class="currentTab == 'purchase' ? 'active' : ''" @tap="changeTab('purchase')"><text>消费购买</text><view></view></view><view class="tab-item" :class="currentTab == 'rights' ? 'active' : ''" @tap="changeTab('rights')"><text>权益详情</text><view></view></view></view>
                    <view v-if="currentTab == 'purchase'" class="coupon-list"><view v-if="listLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载可领电影券</text></view><view v-else-if="listError" class="list-state error-state"><text>电影券加载失败</text><text class="retry-text" @tap="loadCoupons">重新加载</text></view><view v-else-if="listLoaded && coupons.length == 0" class="list-state"><text>暂无可领取电影券</text></view><view class="coupon-item" v-for="item in coupons" :key="item.id"><image class="coupon-image" :src="item.image" mode="aspectFill" @error="replaceCouponImage(item)"></image><view class="coupon-info"><text class="coupon-name">{{item.name}}</text><view class="stock-row"><text>剩余可领</text><text>{{item.stock}}</text><text>张</text></view><view class="coupon-bottom"><view class="coupon-price"><text>¥</text><text>{{item.price}}</text></view><view class="claim-button" :class="item.claimed ? 'claimed' : (item.soldOut ? 'sold-out' : '')" @tap="requestClaimCoupon(item)"><text>{{item.claimed ? '已领取' : (item.soldOut ? '已领完' : '领券')}}</text></view></view></view></view></view>
                    <view v-if="currentTab == 'purchase'" class="coupon-list"><view v-if="listLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载可领电影券</text></view><view v-else-if="listError" class="list-state error-state"><text>电影券加载失败</text><text class="retry-text" @tap="loadCoupons">重新加载</text></view><view v-else-if="listLoaded && coupons.length == 0" class="list-state"><text>暂无可领取电影券</text></view><view class="coupon-item" v-for="item in coupons" :key="item.id"><image class="coupon-image" :src="item.image" mode="aspectFill" @error="replaceCouponImage(item)"></image><view class="coupon-info"><text class="coupon-name">{{item.name}}</text><view class="stock-row"><text>剩余可领</text><text>{{quotaRemaining}}</text><text>张</text></view><view class="coupon-bottom"><view class="coupon-price"><text>¥</text><text>{{item.price}}</text></view><view class="claim-button" :class="item.claimed ? 'claimed' : (item.soldOut ? 'sold-out' : '')" @tap="requestClaimCoupon(item)"><text>{{item.claimed ? '已领取' : (item.soldOut ? '已领完' : '领券')}}</text></view></view></view></view></view>
                    <view v-else class="rights-panel"><text class="rights-title">{{rightsTitle}}</text><text class="rights-description">{{rightsDescription}}</text><view class="rule-row"><text>适用卡种</text><text>{{applicableCards}}</text></view><view class="rule-row"><text>领取价格</text><text>{{claimPrice}}</text></view><view class="rule-row"><text>使用方式</text><text>{{usage}}</text></view><view class="rule-row"><text>使用范围</text><text>{{scope}}</text></view></view>
                </view>
            </view>
@@ -21,7 +21,7 @@
<script setup lang="uts">
    import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
    import { getCinemaMerchant, getCinemaCoupons, claimCinemaCoupon } from '../../services/portal.uts'
    import { getCinemaMerchant, getCinemaCoupons, getBenefitQuota, issueBenefitVerificationCode } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
@@ -31,7 +31,7 @@
    const isSolidNav = ref(false); const currentTab = ref('purchase'); const merchantId = ref(''); const talentId = ref('')
    const cinemaName = ref('影城服务'); const cinemaAddress = ref(''); const cinemaHero = ref(fallbackImage); const cinemaMobile = ref(''); const cinemaLatitude = ref(0); const cinemaLongitude = ref(0)
    const talentCardTag = ref('人才卡专享'); const rightsTitle = ref('人才观影专属权益'); const rightsDescription = ref(''); const applicableCards = ref('以商户说明为准'); const claimPrice = ref('0元'); const usage = ref('到店出示券码'); const scope = ref('普通厅指定场次')
    const detailLoading = ref(true); const detailError = ref(false); const coupons = ref<any[]>([]); const listLoading = ref(false); const listLoaded = ref(false); const listError = ref(false)
    const detailLoading = ref(true); const detailError = ref(false); const quotaRemaining = ref(0); const coupons = ref<any[]>([]); const listLoading = ref(false); const listLoaded = ref(false); const listError = ref(false)
    const showClaimDialog = ref(false); const isSubmitting = ref(false); const pendingCouponId = ref(''); const pendingCouponStock = ref(0)
    onLoad((options: any) => {
@@ -56,9 +56,10 @@
            cinemaMobile.value = data.mobile != null ? data.mobile as string : ''
            cinemaLatitude.value = data.latitude != null ? Number(data.latitude) : 0; cinemaLongitude.value = data.longitude != null ? Number(data.longitude) : 0
            talentCardTag.value = data.talentCardTag != null ? data.talentCardTag as string : talentCardTag.value; rightsTitle.value = data.rightsTitle != null ? data.rightsTitle as string : rightsTitle.value; rightsDescription.value = data.rightsDescription != null ? data.rightsDescription as string : '以商户说明为准'; applicableCards.value = data.applicableCards != null ? data.applicableCards as string : applicableCards.value; claimPrice.value = data.claimPrice != null ? data.claimPrice as string : claimPrice.value; usage.value = data.usage != null ? data.usage as string : usage.value; scope.value = data.scope != null ? data.scope as string : scope.value
            loadCoupons()
            loadCoupons(); loadQuota()
        }, () => { detailLoading.value = false; detailError.value = true })
    }
    const loadQuota = () => { if (merchantId.value == '' || talentId.value == '') return; getBenefitQuota('MOVIE_TICKET', talentId.value, '', merchantId.value, (data: any) => { quotaRemaining.value = data.quotaRemaining != null ? Number(data.quotaRemaining) : 0 }, () => { quotaRemaining.value = 0 }) }
    const loadCoupons = () => {
        if (!isApiConfigured() || merchantId.value == '' || talentId.value == '') { listLoading.value = false; listError.value = true; return }
        listLoading.value = true; listLoaded.value = false; listError.value = false
@@ -73,9 +74,9 @@
    const changeTab = (tab: string) => { currentTab.value = tab }
    const openMap = () => { if (cinemaLatitude.value <= 0 || cinemaLongitude.value <= 0) { uni.showToast({ title: '影城暂未配置导航地址', icon: 'none' }); return }; uni.openLocation({ latitude: cinemaLatitude.value, longitude: cinemaLongitude.value, name: cinemaName.value, address: cinemaAddress.value, scale: 18, fail: () => uni.showToast({ title: '暂时无法打开地图导航', icon: 'none' }) }) }
    const callCinema = () => { if (cinemaMobile.value == '') { uni.showToast({ title: '影城暂未提供联系电话', icon: 'none' }); return }; uni.makePhoneCall({ phoneNumber: cinemaMobile.value, fail: () => uni.showToast({ title: '暂时无法拨打电话', icon: 'none' }) }) }
    const requestClaimCoupon = (item: any) => { if (item.claimed) { uni.showToast({ title: '优惠券已领取', icon: 'none' }); return }; if (item.soldOut) { uni.showToast({ title: '电影券已领完', icon: 'none' }); return }; pendingCouponId.value = item.id; pendingCouponStock.value = item.stock; showClaimDialog.value = true }
    const requestClaimCoupon = (item: any) => { if (item.claimed) { uni.showToast({ title: '优惠券已领取', icon: 'none' }); return }; if (item.soldOut) { uni.showToast({ title: '电影券已领完', icon: 'none' }); return }; if (quotaRemaining.value <= 0) { uni.showToast({ title: '当前暂无可领取次数', icon: 'none' }); return }; pendingCouponId.value = item.id; pendingCouponStock.value = quotaRemaining.value; showClaimDialog.value = true }
    const closeClaimDialog = () => { if (isSubmitting.value) return; showClaimDialog.value = false; pendingCouponId.value = ''; pendingCouponStock.value = 0 }
    const confirmClaimCoupon = () => { if (isSubmitting.value || pendingCouponId.value == '') return; isSubmitting.value = true; claimCinemaCoupon(merchantId.value, pendingCouponId.value, talentId.value, (data: any) => { isSubmitting.value = false; showClaimDialog.value = false; loadCoupons(); uni.navigateTo({ url: '/pages/coupon-received/coupon-received?name=' + encodeURIComponent(data.couponName as string) + '&claimNo=' + encodeURIComponent(data.claimNo as string) + '&validEnd=' + encodeURIComponent(String(data.validEnd)) + '&claimId=' + encodeURIComponent(String(data.claimId)) }) }, (error) => { isSubmitting.value = false; uni.showToast({ title: error.message, icon: 'none' }) }) }
    const confirmClaimCoupon = () => { if (isSubmitting.value || pendingCouponId.value == '') return; isSubmitting.value = true; const requestKey = 'movie-code-' + merchantId.value + '-' + talentId.value + '-' + Date.now(); issueBenefitVerificationCode('MOVIE_TICKET', talentId.value, '', merchantId.value, requestKey, (data: any) => { isSubmitting.value = false; showClaimDialog.value = false; loadCoupons(); loadQuota(); const claimId = data.sourceId != null ? String(data.sourceId) : ''; uni.navigateTo({ url: '/pages/coupon-received/coupon-received?name=' + encodeURIComponent(data.couponName != null ? String(data.couponName) : '电影票兑换券') + '&claimId=' + encodeURIComponent(claimId) + '&merchantId=' + encodeURIComponent(merchantId.value) }) }, (error) => { isSubmitting.value = false; uni.showToast({ title: error.message, icon: 'none' }) }) }
    const replaceHeroImage = () => { cinemaHero.value = fallbackImage }; const replaceCouponImage = (item: any) => { item.image = fallbackImage }
</script>
rencaika/pages/coupon-received/coupon-received.uvue
@@ -1,11 +1,11 @@
<template>
    <view class="page">
        <view class="page-header"><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" hover-class="back-button-active" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view><text class="nav-title">领券成功</text><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view>
        <view class="page-header"><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" hover-class="back-button-active" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view><text class="nav-title">领取成功</text><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view>
        <view v-if="loading" class="page-state"><view class="loading-spinner"></view><text>正在加载电影券</text></view>
        <view v-else-if="loadError" class="page-state error-state"><text>电影券详情加载失败</text><text class="retry-text" @tap="loadClaim">重新加载</text></view>
        <view v-else class="result-card">
            <view class="coupon-heading"><view class="heading-icon"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image></view><text>{{couponName}}</text></view><view class="heading-divider"></view>
            <view v-if="hasAnnual" class="annual-panel"><view class="annual-title-row"><view class="annual-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>年度权益</text></view><view class="remaining-pill"><text>✓</text><text>剩余 {{annualRemaining}} 张</text></view></view><view class="progress-track"><view class="progress-value" :style="'width:' + progressPercent + '%'"></view></view></view>
            <view v-if="hasAnnual" class="annual-panel"><view class="annual-title-row"><view class="annual-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>年度权益</text></view><view class="remaining-pill"><text>✓</text><text>剩余 {{annualRemaining}} 张</text></view></view><view class="progress-track"><view class="progress-value" :style="'width:' + progressPercent + '%'"></view></view><view class="annual-stats"><text>◉ 已领 {{annualClaimed}} 张</text><text>▣ 剩余 {{annualRemaining}} 张</text><text>总计 {{annualTotal}} 张</text></view><text class="annual-caption">年度权益{{annualTotal}}张 / 已领{{annualClaimed}}张 / 剩余{{annualRemaining}}张</text></view>
            <view class="voucher-section"><view class="section-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>兑换凭证</text></view><view v-if="canCopy" class="voucher-code"><text>{{exchangeCode}}</text></view><view v-else class="voucher-unavailable"><text>{{codeStatusText}}</text></view><view class="voucher-tip"><view class="mini-copy-icon"><view></view><view></view></view><text>{{voucherTip}}</text></view></view>
            <view v-if="canCopy" class="copy-button" hover-class="copy-button-active" @tap="copyExchangeCode"><view class="copy-icon"><view></view><view></view></view><text>复制兑换码</text></view>
        </view>
@@ -17,43 +17,23 @@
    import { getCinemaCouponClaim } 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)
    const claimId = ref(''); const talentId = ref(''); const loading = ref(true); const loadError = ref(false)
    const couponName = 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) => {
        // #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 = options['claimId'] 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'
        loadClaim()
    })
    const claimId = ref(''); const merchantId = ref(''); const talentId = ref(''); 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['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
        getCinemaCouponClaim(claimId.value, talentId.value, (data: any) => {
            loading.value = false; couponName.value = data.couponName != null ? data.couponName as string : couponName.value
            if (data.annualTotal != null && data.annualClaimed != null && data.annualRemaining != null) { annualTotal.value = Number(data.annualTotal); annualClaimed.value = Number(data.annualClaimed); annualRemaining.value = Number(data.annualRemaining); hasAnnual.value = annualTotal.value > 0 } else hasAnnual.value = false
            exchangeCode.value = data.exchangeCode != null ? data.exchangeCode as string : ''; exchangeCodeStatus.value = data.exchangeCodeStatus != null ? data.exchangeCodeStatus as string : ''
            if (exchangeCodeStatus.value == 'USED') codeStatusText.value = '兑换码已使用'; else if (exchangeCodeStatus.value == 'EXPIRED') codeStatusText.value = '兑换码已过期或失效'; else if (exchangeCode.value == '') codeStatusText.value = '兑换码生成中,请稍后重试'; else codeStatusText.value = ''
            const validEnd = data.validEnd != null ? String(data.validEnd) : ''; const rule = data.verificationRule != null ? data.verificationRule as string : '请按影城规则使用兑换码'; const reason = data.invalidReason != null ? data.invalidReason as string : ''; voucherTip.value = reason != '' ? reason : (validEnd != '' ? '有效期至 ' + validEnd + '。' + rule : rule)
        }, () => { loading.value = false; loadError.value = true })
    }
    const goBack = () => safeBack('/pages/order/order?status=received')
    const copyExchangeCode = () => { if (!canCopy.value) return; uni.setClipboardData({ data: exchangeCode.value, success: () => uni.showToast({ title: '兑换码已复制', icon: 'success' }), fail: () => uni.showToast({ title: '复制失败,请长按兑换码', icon: 'none' }) }) }
    const loadClaim = () => { if (claimId.value == '' || talentId.value == '') { loading.value = false; loadError.value = true; return }; loading.value = true; loadError.value = false; getCinemaCouponClaim(claimId.value, talentId.value, (data: any) => { loading.value = false; couponName.value = data.couponName != null ? String(data.couponName) : couponName.value; merchantName.value = data.merchantName != null ? String(data.merchantName) : ''; if (data.annualTotal != null && data.annualClaimed != null && data.annualRemaining != null) { annualTotal.value = Number(data.annualTotal); annualClaimed.value = Number(data.annualClaimed); annualRemaining.value = Number(data.annualRemaining); 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 = ''; voucherTip.value = '复制后前往' + (merchantName.value == '' ? '影城' : merchantName.value) + '小程序使用,核销时出示兑换码' }, () => { loading.value = false; loadError.value = true }) }
    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>
<style>
    .page { display: flex; width: 100%; height: 100vh; box-sizing: border-box; overflow: hidden; background-image: linear-gradient(180deg, #FFF0D7 0%, #FFF8EA 25%, #F4F6F9 58%, #F4F6F9 100%); flex-direction: column; }.page-header { position: relative; z-index: 2; flex-shrink: 0; }.safe-space { height: 28px; }.nav-row { display: flex; box-sizing: border-box; padding-left: 20rpx; position: relative; flex-direction: row; align-items: center; justify-content: space-between; }.back-button { display: flex; width: 70rpx; height: 64rpx; margin-left: -4rpx; justify-content: center; align-items: center; }.back-button image { width: 36rpx; height: 36rpx; }.back-button-active { opacity: .55; }.nav-title { position: absolute; left: 50%; top: 50%; color: #171B23; font-size: 36rpx; line-height: 48rpx; font-weight: 700; transform: translate(-50%, -50%); white-space: nowrap; }.capsule-space { height: 100%; flex-shrink: 0; }
    .page-state { display: flex; min-height: 560rpx; color: #6F7885; font-size: 27rpx; flex-direction: column; align-items: center; justify-content: center; }.loading-spinner { width: 42rpx; height: 42rpx; margin-bottom: 22rpx; box-sizing: border-box; border: 5rpx solid #FFDCC8; border-top-color: #F36F31; border-radius: 50%; animation: coupon-spin 750ms linear infinite; }.error-state { color: #7D8490; }.retry-text { margin-top: 20rpx; color: #F26A38; }@keyframes coupon-spin { to { transform: rotate(360deg); } }
    .result-card { display: flex; min-height: 0; flex: 1; margin: 18rpx 20rpx calc(22rpx + env(safe-area-inset-bottom)); box-sizing: border-box; padding: 25rpx 27rpx 0; border-radius: 42rpx; background-color: #FFFFFF; flex-direction: column; }.coupon-heading { display: flex; height: 72rpx; flex-direction: row; align-items: center; }.heading-icon { display: flex; width: 58rpx; height: 58rpx; margin-right: 16rpx; border-radius: 50%; background-color: #FFF3E8; justify-content: center; align-items: center; }.heading-icon image { width: 33rpx; height: 33rpx; }.coupon-heading text { min-width: 0; flex: 1; color: #252A32; font-size: 29rpx; line-height: 42rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.heading-divider { height: 1rpx; margin: 3rpx 0 20rpx; background-color: #EEF0F3; }
    .annual-panel { padding: 24rpx 27rpx 29rpx; border-radius: 28rpx; background-color: #F7F9FC; }.annual-title-row { display: flex; height: 48rpx; flex-direction: row; align-items: center; justify-content: space-between; }.annual-title { display: flex; flex-direction: row; align-items: center; }.annual-title image { width: 27rpx; height: 27rpx; margin-right: 12rpx; }.annual-title text { color: #4F5662; font-size: 27rpx; font-weight: 700; }.remaining-pill { display: flex; height: 43rpx; padding: 0 17rpx; border-radius: 22rpx; background-color: #FFF1E4; color: #FF7C35; font-size: 21rpx; flex-direction: row; align-items: center; }.remaining-pill text:first-child { margin-right: 8rpx; }.progress-track { height: 15rpx; margin-top: 28rpx; overflow: hidden; border-radius: 8rpx; background-color: #E4E8ED; }.progress-value { height: 15rpx; border-radius: 8rpx; background-image: linear-gradient(90deg, #FF4634 0%, #FF8D23 100%); }
    .voucher-section { margin-top: 62rpx; padding: 0 22rpx; }.section-title { display: flex; height: 45rpx; flex-direction: row; align-items: center; }.section-title image { width: 27rpx; height: 27rpx; margin-right: 11rpx; }.section-title text { color: #505762; font-size: 27rpx; font-weight: 700; }.voucher-code, .voucher-unavailable { display: flex; height: 82rpx; margin-top: 10rpx; padding: 0 18rpx; border-radius: 24rpx; background-color: #F6F8FB; justify-content: center; align-items: center; }.voucher-code text { color: #1D2531; font-size: 35rpx; line-height: 52rpx; font-weight: 700; letter-spacing: 3rpx; }.voucher-unavailable text { color: #8C95A1; font-size: 24rpx; text-align: center; }.voucher-tip { display: flex; margin-top: 17rpx; color: #7F8998; font-size: 20rpx; line-height: 29rpx; flex-direction: row; align-items: flex-start; }.voucher-tip text { min-width: 0; flex: 1; }.mini-copy-icon { width: 23rpx; height: 25rpx; margin: 2rpx 12rpx 0 3rpx; position: relative; flex-shrink: 0; }.mini-copy-icon view, .copy-icon view { position: absolute; box-sizing: border-box; border: 2rpx solid currentColor; border-radius: 3rpx; }.mini-copy-icon view:first-child { left: 6rpx; top: 0; width: 14rpx; height: 17rpx; }.mini-copy-icon view:last-child { left: 1rpx; top: 6rpx; width: 14rpx; height: 17rpx; }
    .annual-stats { display: flex; margin-top: 24rpx; color: #66717F; font-size: 21rpx; flex-direction: row; justify-content: space-between; }.annual-caption { display: block; margin-top: 16rpx; color: #A0A8B3; font-size: 20rpx; text-align: center; }.annual-panel { padding: 24rpx 27rpx 29rpx; border-radius: 28rpx; background-color: #F7F9FC; }.annual-title-row { display: flex; height: 48rpx; flex-direction: row; align-items: center; justify-content: space-between; }.annual-title { display: flex; flex-direction: row; align-items: center; }.annual-title image { width: 27rpx; height: 27rpx; margin-right: 12rpx; }.annual-title text { color: #4F5662; font-size: 27rpx; font-weight: 700; }.remaining-pill { display: flex; height: 43rpx; padding: 0 17rpx; border-radius: 22rpx; background-color: #FFF1E4; color: #FF7C35; font-size: 21rpx; flex-direction: row; align-items: center; }.remaining-pill text:first-child { margin-right: 8rpx; }.progress-track { height: 15rpx; margin-top: 28rpx; overflow: hidden; border-radius: 8rpx; background-color: #E4E8ED; }.progress-value { height: 15rpx; border-radius: 8rpx; background-image: linear-gradient(90deg, #FF4634 0%, #FF8D23 100%); }
    .voucher-section { margin-top: 62rpx; padding: 0 22rpx; }.section-title { display: flex; height: 45rpx; flex-direction: row; align-items: center; }.section-title image { width: 27rpx; height: 27rpx; margin-right: 11rpx; }.section-title text { color: #505762; font-size: 27rpx; font-weight: 700; }.voucher-code, .voucher-unavailable { display: flex; height: 82rpx; margin-top: 10rpx; padding: 0 18rpx; border-radius: 24rpx; background-color: #F6F8FB; justify-content: center; align-items: center; }voucher-code text { color: #1D2531; font-size: 35rpx; line-height: 52rpx; font-weight: 700; letter-spacing: 3rpx; }.voucher-unavailable text { color: #8C95A1; font-size: 24rpx; text-align: center; }.voucher-tip { display: flex; margin-top: 17rpx; color: #7F8998; font-size: 20rpx; line-height: 29rpx; flex-direction: row; align-items: flex-start; }.voucher-tip text { min-width: 0; flex: 1; }.mini-copy-icon { width: 23rpx; height: 25rpx; margin: 2rpx 12rpx 0 3rpx; position: relative; flex-shrink: 0; }.mini-copy-icon view, .copy-icon view { position: absolute; box-sizing: border-box; border: 2rpx solid currentColor; border-radius: 3rpx; }.mini-copy-icon view:first-child { left: 6rpx; top: 0; width: 14rpx; height: 17rpx; }.mini-copy-icon view:last-child { left: 1rpx; top: 6rpx; width: 14rpx; height: 17rpx; }
    .copy-button { display: flex; width: 100%; height: 88rpx; margin-top: auto; margin-bottom: 150rpx; flex-shrink: 0; border-radius: 46rpx; color: #FFFFFF; background-image: linear-gradient(90deg, #FF4434 0%, #FF8D21 100%); box-shadow: 0 15rpx 28rpx rgba(255,103,39,.21); flex-direction: row; justify-content: center; align-items: center; }.copy-button-active { opacity: .78; }.copy-button text { font-size: 30rpx; font-weight: 600; }.copy-icon { width: 31rpx; height: 31rpx; margin-right: 16rpx; position: relative; }.copy-icon view:first-child { left: 9rpx; top: 1rpx; width: 18rpx; height: 21rpx; }.copy-icon view:last-child { left: 2rpx; top: 8rpx; width: 18rpx; height: 21rpx; }
</style>
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue
@@ -4,7 +4,7 @@
            <view class="nav-bg"></view>
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="back-button" @tap="goBack"><image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></image></view>
                <view class="back-button" hover-class="back-button-active" @tap.stop="goBack"><image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></image></view>
                <text class="nav-title">娱—怡乐生活</text>
                <view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
            </view>
@@ -23,6 +23,7 @@
            <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section">
                <view class="section-heading"><text>观影服务</text><view></view></view>
                <view class="ticket-entry" @tap="openTicketPackage"><text>电影票兑换券领取</text><text class="ticket-arrow">›</text></view>
                <view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载观影服务</text></view>
                <view v-else-if="initialError" class="list-state error-state"><text>观影服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
                <view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state"><text>暂无相关观影服务</text></view>
@@ -163,10 +164,12 @@
    const replaceHeroImage = () => { currentScene.value.image = fallbackImage }
    const replaceSceneImage = (index: number) => { if (index >= 0 && index < scenes.value.length) scenes.value[index].image = fallbackImage }
    const replaceServiceImage = (item: any) => { item.image = fallbackImage }
    const openTicketPackage = () => uni.navigateTo({ url: '/pages/ticket-package/ticket-package' })
    const goBack = () => safeBack('/pages/benefits/benefits')
    const openService = (item: any) => {
        if (!item.available) { uni.showToast({ title: '该服务已下架', icon: 'none' }); return }
        if (item.merchantType != 'cinema') { uni.showToast({ title: '剧院服务详情暂未开放', icon: 'none' }); return }
        const cinemaTypes = ['cinema', 'theatre', '电影院', '影院', '影城', '剧院']
        if (cinemaTypes.indexOf(item.merchantType) < 0) { uni.showToast({ title: '剧院服务详情暂未开放', icon: 'none' }); return }
        uni.navigateTo({ url: '/pages/cinema-merchant/cinema-merchant?merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) })
    }
</script>
@@ -176,10 +179,13 @@
    .custom-nav { position: fixed; z-index: 20; left: 0; right: 0; top: 0; padding-bottom: 20rpx; }
    .nav-bg { position: absolute; left: 0; right: 0; top: 0; bottom: 0; opacity: 0; background-image: linear-gradient(180deg, #F26A38 0%, #EE542B 100%); transition: opacity 280ms ease-out; }
    .custom-nav.is-solid .nav-bg { opacity: 1; }.safe-space, .nav-row { position: relative; z-index: 1; }.safe-space { height: 28px; }
    .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 24rpx; padding-right: 10px; flex-direction: row; align-items: center; justify-content: space-between; }.back-button { display: flex; width: 54rpx; height: 54rpx; border-radius: 50%; background-color: rgba(45,45,45,.52); justify-content: center; align-items: center; }.custom-nav.is-solid .back-button { background-color: rgba(45,45,45,0); }.back-button image { width: 38rpx; height: 38rpx; }.nav-title { position: absolute; left: 0; right: 0; color: #FFFFFF; font-size: 31rpx; font-weight: 500; text-align: center; text-shadow: 0 2rpx 8rpx rgba(0,0,0,.35); }.capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 20rpx; padding-right: 10px; position: relative; flex-direction: row; align-items: center; justify-content: space-between; }.back-button { display: flex; width: 72rpx; height: 64rpx; position: relative; z-index: 4; margin-left: -4rpx; border-radius: 50%; background-color: rgba(45,45,45,.52); justify-content: center; align-items: center; }.back-button-active { opacity: .55; }.custom-nav.is-solid .back-button { background-color: rgba(45,45,45,0); }.back-button image { width: 38rpx; height: 38rpx; pointer-events: none; }.nav-title { position: absolute; z-index: 2; left: 0; right: 0; color: #FFFFFF; font-size: 31rpx; font-weight: 500; text-align: center; text-shadow: 0 2rpx 8rpx rgba(0,0,0,.35); pointer-events: none; }.nav-bg { pointer-events: none; }.capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .hero { height: 730rpx; position: relative; overflow: visible; background-color: #241C2A; }.hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; }.hero-shade { position: absolute; left: 0; right: 0; top: 0; height: 360rpx; background-image: linear-gradient(180deg, rgba(0,0,0,.6) 0%, rgba(0,0,0,0) 100%); }.scene-name { position: absolute; z-index: 2; left: 38rpx; top: 300rpx; color: #FFFFFF; font-size: 47rpx; line-height: 62rpx; font-weight: 500; text-shadow: 0 3rpx 10rpx rgba(0,0,0,.55); }.no-gallery { position: absolute; z-index: 2; left: 0; right: 0; top: 480rpx; color: #FFFFFF; font-size: 25rpx; text-align: center; }
    .scene-scroll { position: absolute; z-index: 4; left: 0; right: 0; top: 469rpx; width: 100%; height: 150rpx; white-space: nowrap; }.scene-row { display: flex; height: 150rpx; padding: 0; flex-direction: row; align-items: center; }.scene-pad { width: 322rpx; height: 1rpx; flex-shrink: 0; }.scene-item { display: flex; width: 106rpx; height: 106rpx; margin-right: 31rpx; box-sizing: border-box; overflow: hidden; position: relative; z-index: 1; border-width: 3rpx; border-style: solid; border-color: rgba(255,255,255,.9); border-radius: 8rpx; flex-shrink: 0; transition: transform 180ms ease-out; }.scene-item image { width: 100%; height: 100%; }.scene-item.active { z-index: 3; border-width: 4rpx; border-color: #FFD329; transform: scale(1.28); box-shadow: 0 8rpx 18rpx rgba(0,0,0,.28); }
    .topic-summary { position: relative; z-index: 4; margin-top: -66rpx; }.intro-card { margin: 0 31rpx; box-sizing: border-box; padding: 25rpx 31rpx; border-radius: 26rpx; background-image: linear-gradient(90deg, #FF5320 0%, #FF8A22 100%); box-shadow: 0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; }
    .service-section { padding: 48rpx 31rpx 0; }.section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; }.section-heading text { display: block; color: #363636; font-size: 32rpx; font-weight: 700; }.section-heading view { width: 61rpx; height: 7rpx; margin: 12rpx 0 18rpx; border-radius: 4rpx; background-image: linear-gradient(90deg, #FF4D36 0%, #FFBF3A 100%); }.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; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.discount-tag { display: block; width: fit-content; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }.address { display: flex; min-width: 0; box-sizing: border-box; padding: 7rpx 10rpx; border-radius: 7rpx; background-image: linear-gradient(90deg, rgba(239,240,242,.98) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); flex-direction: row; align-items: flex-start; }.address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; flex-shrink: 0; }.address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .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: entertainment-loading 720ms linear infinite; }@keyframes entertainment-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }.bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
    .ticket-entry { display: flex; height: 72rpx; margin: 20rpx 0 4rpx; box-sizing: border-box; padding: 0 26rpx; border-radius: 18rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; justify-content: space-between; }
    .ticket-entry text { color: #FF5935; font-size: 25rpx; font-weight: 600; }
    .ticket-arrow { color: #C2C8D0; font-size: 34rpx; }
</style>
rencaika/pages/free-benefit/free-benefit.uvue
@@ -18,15 +18,24 @@
                <text class="benefit-name">{{couponName}}</text>
                <view class="voucher-code" hover-class="voucher-code-active" @tap="refreshCode">
                    <image v-if="codeImage != ''" class="code-image" :src="codeImage" mode="aspectFit" @error="handleCodeImageError"></image>
                    <text v-else-if="codeText != ''" class="code-value">{{codeText}}</text>
                    <view v-else class="code-state">
                        <view v-if="codeLoading" class="loading-spinner small-spinner"></view>
                        <text>{{codeMessage}}</text>
                        <text v-if="codeFailed" class="retry-text" @tap.stop="refreshCode">点击重试</text>
                    </view>
                </view>
                <text class="code-tip">{{codeImage != '' ? '动态券码将自动刷新,点击二维码可手动刷新' : '二维码不可用时请勿向商家出示'}}</text>
                <text class="remaining-count">剩余可用 {{remainingCount}} 次</text>
                <text class="valid-end">有效期至 {{validEnd}}</text>
                <text class="code-tip">{{codeImage != '' ? '免费券码60秒内有效,点击二维码可重新生成' : (codeText != '' ? '二维码不可用,请向商家出示完整核销码' : '核销码不可用时请勿向商家出示')}}</text>
                <text class="countdown-text">{{countdown > 0 ? countdown + '秒后失效' : '核销码已失效'}}</text>
                <view class="quota-overview">
                    <view class="quota-item"><text class="quota-value">{{quotaTotal}}</text><text class="quota-label">可用总次数</text></view>
                    <view class="quota-line"></view>
                    <view class="quota-item"><text class="quota-value used-value">{{quotaUsed}}</text><text class="quota-label">已使用</text></view>
                    <view class="quota-line"></view>
                    <view class="quota-item"><text class="quota-value remaining-value">{{remainingCount}}</text><text class="quota-label">当前剩余</text></view>
                </view>
                <text class="reset-cycle">额度重置周期:{{resetCycle}}</text>
                <text class="valid-end">券码有效期至 {{validEnd}}</text>
                <view class="card-divider"></view>
                <view class="benefit-detail">
                    <text class="detail-title">权益详情</text>
@@ -39,8 +48,8 @@
</template>
<script setup lang="uts">
    import { apiEnvironment } from '../../config/api.uts'
    import { getFreeBenefit, issueFreeBenefitCode } from '../../services/portal.uts'
    import { apiEnvironment, getApiEndpoint, isApiConfigured } from '../../config/api.uts'
    import { getBenefitQuota, issueBenefitVerificationCode } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
@@ -50,20 +59,28 @@
    const navWidth = ref(88)
    const navRight = ref(10)
    const talentId = ref('')
    const cardId = ref('')
    const merchantId = ref('')
    const loading = ref(true)
    const loadFailed = ref(false)
    const available = ref(false)
    const unavailableReason = ref('该权益当前不可使用')
    const couponName = ref('免费入住券')
    const quotaTotal = ref(0)
    const quotaUsed = ref(0)
    const remainingCount = ref(0)
    const resetCycle = ref('-')
    const countdown = ref(0)
    const validEnd = ref('-')
    const description = ref('')
    const codeImage = ref('')
    const codeText = ref('')
    const codeLoading = ref(false)
    const codeFailed = ref(false)
    const codeMessage = ref('正在生成动态券码')
    let codeRefreshTimer: number | null = null
    let countdownTimer: number | null = null
    const codeRequestKey = ref('')
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -76,6 +93,7 @@
        // #endif
        if (options != null && options['merchantId'] != null) merchantId.value = options['merchantId'] as string
        if (options != null && options['talentId'] != null) talentId.value = options['talentId'] as string
        if (options != null && options['cardId'] != null) cardId.value = options['cardId'] as string
        const session = getSession()
        if (talentId.value == '' && session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
@@ -90,53 +108,114 @@
    const clearCodeTimer = () => {
        if (codeRefreshTimer != null) { clearTimeout(codeRefreshTimer); codeRefreshTimer = null }
        if (countdownTimer != null) { clearInterval(countdownTimer); countdownTimer = null }
        countdown.value = 0
    }
    const loadBenefit = () => {
    const startCountdown = (seconds: number) => {
        if (countdownTimer != null) { clearInterval(countdownTimer); countdownTimer = null }
        countdown.value = Math.max(0, seconds)
        countdownTimer = setInterval(() => {
            if (countdown.value > 0) countdown.value = countdown.value - 1
            if (countdown.value <= 0 && countdownTimer != null) { clearInterval(countdownTimer); countdownTimer = null }
        }, 1000)
    }
    const createCodeRequestKey = (): string => 'benefit-code-HOTEL-' + merchantId.value + '-' + talentId.value + '-' + Date.now() + '-' + Math.floor(Math.random() * 1000000)
    const loadBenefit = () => requestQuota()
    const refreshCode = () => requestVerificationCode(false)
    const requestQuota = () => {
        if (!isApiConfigured() || merchantId.value == '' || (talentId.value == '' && cardId.value == '')) {
            loading.value = false
            loadFailed.value = true
            return
        }
        clearCodeTimer()
        loading.value = true
        loadFailed.value = false
        available.value = false
        codeLoading.value = false
        codeFailed.value = false
        codeImage.value = ''
        getFreeBenefit(talentId.value, merchantId.value, (data: any) => {
        codeText.value = ''
        getBenefitQuota('HOTEL', talentId.value, cardId.value, merchantId.value, (data: any) => {
            loading.value = false
            available.value = data != null && data.available === true
            if (!available.value) {
                unavailableReason.value = data != null && data.reason != null ? data.reason as string : '该权益当前不可使用'
            if (data == null) {
                available.value = false
                unavailableReason.value = '未查询到当前酒店权益额度'
                return
            }
            couponName.value = data.name != null ? data.name as string : '免费入住券'
            remainingCount.value = data.remainingCount != null ? Number(data.remainingCount) : 0
            validEnd.value = data.validEnd != null ? String(data.validEnd) : '以商户说明为准'
            description.value = data.description != null && String(data.description).length > 0 ? data.description as string : '以商户说明为准'
            quotaTotal.value = data.quotaTotal != null ? Number(data.quotaTotal) : 0
            quotaUsed.value = data.quotaUsed != null ? Number(data.quotaUsed) : 0
            remainingCount.value = data.quotaRemaining != null ? Number(data.quotaRemaining) : 0
            resetCycle.value = data.quotaResetCycle != null && String(data.quotaResetCycle) != '' ? String(data.quotaResetCycle) : '-'
            const familyCard = data.familyCard === true
            couponName.value = familyCard ? '亲属卡共享免费入住权益' : '人才卡免费入住权益'
            description.value = familyCard ? '当前亲属附属卡已自动关联主卡,共享主卡的酒店权益额度;核销成功后将自动扣减主卡剩余次数。' : '核销成功后将自动扣减当前主卡的酒店权益次数。'
            available.value = data.available === true && remainingCount.value > 0
            if (!available.value) {
                unavailableReason.value = remainingCount.value <= 0 ? '当前酒店权益可用次数已用完' : '该权益当前不可使用'
                return
            }
            refreshCode()
        }, () => {
        }, (error) => {
            loading.value = false
            loadFailed.value = true
            available.value = false
            unavailableReason.value = error.message == '' ? '酒店权益额度查询失败' : error.message
        })
    }
    const refreshCode = () => {
        if (!available.value || talentId.value == '' || codeLoading.value) return
    const requestVerificationCode = (initialLoad: boolean) => {
        if (!isApiConfigured() || merchantId.value == '' || (talentId.value == '' && cardId.value == '') || codeLoading.value) {
            if (initialLoad) { loading.value = false; loadFailed.value = true }
            return
        }
        clearCodeTimer()
        if (initialLoad) { loading.value = true; loadFailed.value = false; available.value = false }
        codeLoading.value = true
        codeFailed.value = false
        codeImage.value = ''
        codeMessage.value = '正在生成动态券码'
        issueFreeBenefitCode(talentId.value, merchantId.value, (data: any) => {
        codeText.value = ''
        codeMessage.value = '正在生成60秒免费券码'
        if (codeRequestKey.value == '') codeRequestKey.value = createCodeRequestKey()
        issueBenefitVerificationCode('HOTEL', talentId.value, cardId.value, merchantId.value, codeRequestKey.value, (data: any) => {
            loading.value = false
            codeLoading.value = false
            if (data == null || data.imagePath == null) { handleCodeFailure('二维码生成失败'); return }
            codeImage.value = resolveApiUrl(data.imagePath as string)
            const seconds = data.refreshAfterSeconds != null ? Number(data.refreshAfterSeconds) : 60
            codeRefreshTimer = setTimeout(() => refreshCode(), Math.max(10, seconds - 3) * 1000)
        }, (error) => handleCodeFailure(error.message))
            codeRequestKey.value = ''
            if (data == null || data.verifyCode == null) { handleCodeFailure('酒店权益核销码生成失败'); return }
            available.value = true
            const familyCard = data.familyCard === true
            couponName.value = familyCard ? '亲属卡共享免费入住权益' : '人才卡免费入住权益'
            quotaTotal.value = data.quotaTotal != null ? Number(data.quotaTotal) : quotaTotal.value
            quotaUsed.value = data.quotaUsed != null ? Number(data.quotaUsed) : quotaUsed.value
            remainingCount.value = data.quotaRemainingBefore != null ? Number(data.quotaRemainingBefore) : remainingCount.value
            resetCycle.value = data.quotaResetCycle != null && String(data.quotaResetCycle) != '' ? String(data.quotaResetCycle) : resetCycle.value
            const expiresAt = data.expiresAt != null ? String(data.expiresAt).replace('T', ' ') : ''
            validEnd.value = expiresAt.length > 19 ? expiresAt.substring(0, 19) : (expiresAt == '' ? '生成后60秒' : expiresAt)
            description.value = familyCard ? '当前亲属附属卡已自动关联主卡,共享主卡的酒店权益额度;核销成功后将自动扣减主卡剩余次数。' : '核销成功后将自动扣减当前主卡的酒店权益次数。'
            const token = String(data.verifyCode)
            codeText.value = token
            codeImage.value = resolveApiUrl(getApiEndpoint('appFreeBenefits') + '/dynamic-codes/' + encodeURIComponent(token) + '/image')
            const seconds = data.expiresInSeconds != null ? Math.max(1, Number(data.expiresInSeconds)) : (data.refreshAfterSeconds != null ? Number(data.refreshAfterSeconds) : 60)
            startCountdown(seconds)
            codeRefreshTimer = setTimeout(() => refreshCode(), Math.max(5, seconds - 2) * 1000)
        }, (error) => {
            loading.value = false
            if (initialLoad) {
                codeLoading.value = false
                available.value = false
                unavailableReason.value = error.message == '' ? '该权益当前不可使用' : error.message
                return
            }
            handleCodeFailure(error.message)
        })
    }
    const handleCodeFailure = (message: string) => {
        clearCodeTimer()
        codeLoading.value = false
        codeImage.value = ''
        codeText.value = ''
        codeFailed.value = true
        codeMessage.value = message == '' ? '二维码已失效,点击重试' : message
        codeMessage.value = message == '' ? '核销码已失效,点击重试' : message
    }
    const handleCodeImageError = () => handleCodeFailure('二维码已失效,点击重试')
    const handleCodeImageError = () => { codeImage.value = ''; codeFailed.value = false }
    const goBack = () => safeBack('/pages/stay-benefits/stay-benefits')
</script>
@@ -157,9 +236,18 @@
    .voucher-code { display: flex; width: 260rpx; height: 260rpx; margin: 32rpx auto 0; overflow: hidden; border-radius: 14rpx; background-color: #F7F7F8; justify-content: center; align-items: center; }
    .voucher-code-active { opacity: .72; }
    .code-image { width: 100%; height: 100%; background-color: #FFFFFF; }
    .code-value { width: 220rpx; color: #313947; font-size: 20rpx; line-height: 29rpx; text-align: center; word-break: break-all; }
    .code-state { display: flex; max-width: 220rpx; color: #69727F; font-size: 25rpx; line-height: 36rpx; text-align: center; flex-direction: column; align-items: center; }
    .code-tip { display: block; margin: 14rpx 35rpx 0; color: #99A1AB; font-size: 21rpx; line-height: 30rpx; text-align: center; }
    .remaining-count { display: block; margin-top: 16rpx; color: #626C78; font-size: 25rpx; line-height: 34rpx; font-weight: 600; text-align: center; }
    .countdown-text { display: block; margin-top: 8rpx; color: #F07838; font-size: 24rpx; line-height: 34rpx; font-weight: 600; text-align: center; }
    .quota-overview { display: flex; margin: 24rpx 32rpx 0; padding: 22rpx 12rpx; border-radius: 14rpx; background-color: #FFF8EF; flex-direction: row; align-items: center; justify-content: space-around; }
    .quota-item { display: flex; min-width: 150rpx; flex-direction: column; align-items: center; }
    .quota-value { color: #313947; font-size: 36rpx; line-height: 44rpx; font-weight: 700; }
    .used-value { color: #8B94A0; }
    .remaining-value { color: #F07838; }
    .quota-label { margin-top: 5rpx; color: #8E97A4; font-size: 20rpx; line-height: 28rpx; }
    .quota-line { width: 1rpx; height: 52rpx; background-color: #EADCCB; }
    .reset-cycle { display: block; margin-top: 16rpx; color: #626C78; font-size: 23rpx; line-height: 32rpx; text-align: center; }
    .valid-end { display: block; margin-top: 7rpx; color: #8E97A4; font-size: 22rpx; line-height: 31rpx; text-align: center; }
    .card-divider { height: 1rpx; margin-top: 34rpx; background-color: #ECEEF1; }
    .benefit-detail { padding: 34rpx 20rpx 24rpx; }
rencaika/pages/merchant/merchant.uvue
@@ -122,7 +122,7 @@
<script setup lang="uts">
    import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
    import { getFuelMerchantDetail, getMedicalPackages, getMerchantDetail, getParkingMerchantDetail, getProducts, submitFuelMobileRegistration } from '../../services/portal.uts'
    import { getBenefitQuota, getFuelMerchantDetail, getMedicalPackages, getMerchantDetail, getParkingMerchantDetail, getProducts, submitFuelMobileRegistration } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
@@ -289,7 +289,7 @@
                    merchantServiceLabel.value = '加油服务'
                    merchantRightsText.value = '持卓越人才卡到站加油并出示人才码,可享指定油品 9.4 折优惠,具体适用油品、优惠上限、可用时段和活动库存以加油站现场说明为准。'
                }
            } else if (routedMerchantType == 'hotel' || name.indexOf('酒店') >= 0) {
            } else if (routedMerchantType == 'hotel' || routedMerchantType == '酒店服务' || name.indexOf('酒店') >= 0) {
                isHotelMerchant.value = true
                merchantType.value = 'hotel'
                merchantName.value = name
@@ -477,8 +477,13 @@
            merchantRightsText.value = data != null && data.rightsText != null && data.rightsText != '' ? data.rightsText as string : merchantRightsText.value
            usageInstructions.value = data != null && data.usageInstructions != null && data.usageInstructions != '' ? data.usageInstructions as string : usageInstructions.value
            groupBuyingEnabled.value = data == null || data.groupBuyingEnabled == null ? groupBuyingEnabled.value : data.groupBuyingEnabled === true || Number(data.groupBuyingEnabled) == 1
            isHotelMerchant.value = merchantType.value == 'hotel'
            isHotelMerchant.value = merchantType.value == 'hotel' || merchantType.value == '酒店服务'
            hasFreeVoucher.value = merchantDiscount.value.indexOf('免费') >= 0
            if (isHotelMerchant.value && /^\d+$/.test(merchantId.value) && talentId.value != '') {
                getBenefitQuota('HOTEL', talentId.value, '', merchantId.value, (quota: any) => {
                    hasFreeVoucher.value = quota != null && Number(quota.quotaTotal) > 0
                }, () => {})
            }
            merchantLocation.value = {
                latitude: data != null && data.latitude != null ? Number(data.latitude) : 0,
                longitude: data != null && data.longitude != null ? Number(data.longitude) : 0,
rencaika/pages/shopping-benefits/shopping-benefits.uvue
@@ -6,6 +6,7 @@
            <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadShoppingTopic">重新加载</text></view>
            <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section">
                <view class="policy-entry" @tap="openDisplayPolicies"><text>展示类服务政策</text><text class="policy-arrow">›</text></view>
                <scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 186) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view>
                <view v-if="initialLoading" class="service-empty"><view class="loading-spinner"></view><text>正在加载购物服务</text></view>
                <view v-else-if="initialError" class="service-empty error-state"><text>购物服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
@@ -74,6 +75,7 @@
        }, () => { 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) }) }
</script>
@@ -84,4 +86,7 @@
    .hero { height:730rpx; position:relative; overflow:visible; background-color:#1C315E; }.hero-image { position:absolute; left:0; top:0; width:100%; height:730rpx; }.hero-top-shade { position:absolute; left:0; right:0; top:0; height:245rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.62),rgba(0,0,0,0)); }.hero-bottom-shade { position:absolute; left:0; right:0; bottom:0; height:230rpx; background-image:linear-gradient(180deg,rgba(0,0,0,0),rgba(0,0,0,.42)); }.scene-name { position:absolute; z-index:3; left:38rpx; top:318rpx; color:#FFFFFF; font-family:'FoodCalligraphy'; font-size:48rpx; line-height:66rpx; text-shadow:0 3rpx 10rpx rgba(0,0,0,.55); }.scene-scroll { position:absolute; z-index:4; left:0; right:0; top:469rpx; width:100%; height:150rpx; white-space:nowrap; }.scene-row { display:flex; height:150rpx; flex-direction:row; align-items:center; }.scene-pad { width:322rpx; height:1rpx; flex-shrink:0; }.scene-item { display:flex; width:106rpx; height:106rpx; margin-right:31rpx; box-sizing:border-box; overflow:hidden; position:relative; z-index:1; border:3rpx solid rgba(255,255,255,.9); border-radius:8rpx; flex-shrink:0; transition:transform 180ms ease-out; }.scene-item image { width:100%; height:100%; }.scene-item.active { z-index:3; border-width:4rpx; border-color:#FFD329; transform:scale(1.28); box-shadow:0 8rpx 18rpx rgba(0,0,0,.28); }
    .topic-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; padding:25rpx 31rpx; border-radius:26rpx; background-image:linear-gradient(90deg,#FF5320,#FF8A22); box-shadow:0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color:#FFFFFF; font-size:23rpx; line-height:34rpx; }.service-section { padding:48rpx 31rpx 0; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:144rpx; margin-right:42rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:29rpx; font-weight:600; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; }
    .service-list { padding-top:10rpx; }.service-item { display:flex; min-height:215rpx; margin-bottom:22rpx; flex-direction:row; align-items:flex-start; }.service-item.is-unavailable { opacity:.55; }.service-image { width:184rpx; height:184rpx; border-radius:17rpx; flex-shrink:0; background-color:#EEEEEE; }.service-info { display:flex; min-width:0; min-height:184rpx; flex:1; box-sizing:border-box; padding:4rpx 0 4rpx 24rpx; flex-direction:column; justify-content:space-between; }.service-name { color:#292929; font-size:29rpx; line-height:40rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.service-discount { align-self:flex-start; max-width:400rpx; padding:6rpx 9rpx; border-radius:6rpx; background-color:#FFF5E7; color:#FF7C2B; font-size:21rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.address-row { display:flex; min-width:0; box-sizing:border-box; padding:7rpx 10rpx; border-radius:7rpx; background-image:linear-gradient(90deg,rgba(239,240,242,.98),rgba(242,243,245,.76) 58%,rgba(255,255,255,0)); flex-direction:row; align-items:flex-start; }.address-row image { width:27rpx; height:27rpx; margin-right:8rpx; flex-shrink:0; }.address-row text { min-width:0; flex:1; color:#7E8490; font-size:21rpx; line-height:31rpx; max-height:62rpx; overflow:hidden; }.service-empty { display:flex; height:310rpx; justify-content:center; align-items:center; }.service-empty text { color:#A5A8AD; font-size:25rpx; }.error-state { flex-direction:column; }.error-state .retry-text { margin-top:22rpx; color:#F26A38; }.loading-row,.no-more { display:flex; height:90rpx; flex-direction:row; justify-content:center; align-items:center; }.loading-spinner { width:28rpx; height:28rpx; margin-right:13rpx; box-sizing:border-box; border:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:shopping-loading 720ms linear infinite; }.loading-row text { color:#989DA5; font-size:22rpx; }.load-error text { color:#F26A38; }.no-more view { width:70rpx; height:1rpx; background-color:#E7E7E7; }.no-more text { margin:0 18rpx; color:#B1B3B7; font-size:21rpx; }.bottom-space { height:calc(30rpx + env(safe-area-inset-bottom)); }@keyframes shopping-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
    .policy-entry { display: flex; height: 72rpx; margin: 20rpx 0 4rpx; box-sizing: border-box; padding: 0 26rpx; border-radius: 18rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; justify-content: space-between; }
    .policy-entry text { color: #FF5935; font-size: 25rpx; font-weight: 600; }
    .policy-arrow { color: #C2C8D0; font-size: 34rpx; }
</style>
rencaika/services/portal.uts
@@ -1,5 +1,5 @@
import { getApiEndpoint } from '../config/api.uts'
import { request, HttpError } from './http.uts'
import { request, HttpError, resolveApiUrl } from './http.uts'
// Public portal data is intentionally separate from authenticated talent services.
export const getAppHome = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
@@ -74,7 +74,21 @@
export const getCinemaMerchant = (merchantId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appCinemaMerchants') + encodeURIComponent(merchantId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getCinemaCoupons = (merchantId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appCinemaMerchants') + encodeURIComponent(merchantId) + '/coupons', method: 'GET', skipAuth: true, data: { talentId: talentId } }, onSuccess, onError)
export const claimCinemaCoupon = (merchantId: string, couponId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appCinemaMerchants') + encodeURIComponent(merchantId) + '/coupons/' + encodeURIComponent(couponId) + '/claims', method: 'POST', skipAuth: true, data: { talentId: Number(talentId) }, idempotencyKey: 'cinema-claim-' + merchantId + '-' + couponId + '-' + talentId }, onSuccess, onError)
export const issueBenefitVerificationCode = (benefitType: string, talentId: string, cardId: string, merchantId: string, idempotencyKey: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { benefitType: benefitType, merchantId: Number(merchantId) }
    if (talentId != '') data.talentId = Number(talentId)
    if (cardId != '') data.cardId = Number(cardId)
    return request<any>({ path: getApiEndpoint('appBenefitVerificationCodes'), method: 'POST', data: data, idempotencyKey: idempotencyKey }, onSuccess, onError)
}
export const getBenefitQuota = (benefitType: string, talentId: string, cardId: string, merchantId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { benefitType: benefitType, merchantId: Number(merchantId) }
    if (talentId != '') data.talentId = Number(talentId)
    if (cardId != '') data.cardId = Number(cardId)
    return request<any>({ path: getApiEndpoint('appBenefitVerificationCodes') + '/quota', method: 'GET', data: data }, onSuccess, onError)
}
export const getCinemaCouponClaim = (claimId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appCinemaCouponClaims') + encodeURIComponent(claimId), method: 'GET', skipAuth: true, data: { talentId: talentId } }, onSuccess, onError)
export const getCinemaCodeImageUrl = (claimId: string): string => resolveApiUrl(getApiEndpoint('appBenefitVerificationCodes') + '/' + encodeURIComponent(claimId) + '/image')
export const searchMerchants = (keyword: string, categoryId: string, mode: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    return request<any>({
@@ -129,6 +143,39 @@
    return request<any>({ path: getApiEndpoint('appFamilyCardAccess'), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getAgreement = (type: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appAgreements') + encodeURIComponent(type), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getTicketPackages = (talentId: string, cardId: string, merchantId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = {}
    if (talentId != '') data.talentId = Number(talentId)
    if (cardId != '') data.cardId = Number(cardId)
    if (merchantId != '') data.merchantId = Number(merchantId)
    return request<any>({ path: getApiEndpoint('appTicketPackages'), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const claimTicketPackage = (packageId: string, talentId: string, cardId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { talentId: Number(talentId) }
    if (cardId != '') data.cardId = Number(cardId)
    return request<any>({ path: getApiEndpoint('appTicketPackages') + '/' + encodeURIComponent(packageId) + '/claims', method: 'POST', skipAuth: true, data: data, idempotencyKey: 'ticket-package-claim-' + packageId + '-' + talentId + '-' + Date.now() }, onSuccess, onError)
}
export const getDisplayMerchantPolicies = (talentId: string, cardId: string, merchantType: string, keyword: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { page: page, size: size }
    if (talentId != '') data.talentId = Number(talentId)
    if (cardId != '') data.cardId = Number(cardId)
    if (merchantType != '') data.merchantType = merchantType
    if (keyword != '') data.keyword = keyword
    return request<any>({ path: getApiEndpoint('appDisplayMerchantPolicies'), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getDisplayMerchantPolicyDetail = (merchantId: string, talentId: string, cardId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = {}
    if (talentId != '') data.talentId = Number(talentId)
    if (cardId != '') data.cardId = Number(cardId)
    return request<any>({ path: getApiEndpoint('appDisplayMerchantPolicies') + '/' + encodeURIComponent(merchantId), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const recordDisplayMerchantPolicyEvent = (merchantId: string, talentId: string, cardId: string, eventType: string, remark: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { eventType: eventType }
    if (talentId != '') data.talentId = Number(talentId)
    if (cardId != '') data.cardId = Number(cardId)
    if (remark != '') data.remark = remark
    return request<any>({ path: getApiEndpoint('appDisplayMerchantPolicies') + '/' + encodeURIComponent(merchantId) + '/events', method: 'POST', skipAuth: true, data: data }, onSuccess, onError)
}
export const issueTalentDynamicCode = (talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    return request<any>({
talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java
@@ -6,15 +6,22 @@
import com.talentcard.server.request.BenefitVerificationCodeRequest;
import com.talentcard.server.service.BenefitVerificationCodeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "小程序-权益核销码", description = "小程序生成电影票、酒店权益5分钟核销码")
@Tag(name = "小程序-权益核销码", description = "小程序查询电影票、酒店权益额度并生成动态核销码")
@RestController
@RequestMapping("/api/app/benefit-verification-codes")
public class BenefitVerificationCodeController {
@@ -24,7 +31,32 @@
        this.service = service;
    }
    @Operation(summary = "生成权益核销码", description = "根据主卡/亲属附属卡共享额度、商户剩余券或酒店库存生成5分钟有效核销码")
    @Operation(summary = "获取电影票兑换码二维码", description = "返回电影票固定兑换码二维码,核销或电影券有效期结束后失效")
    @GetMapping(value = "/{claimId}/image", produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> movieImage(
            @Parameter(description = "电影票领取记录ID", required = true) @PathVariable Long claimId) {
        return ResponseEntity.ok()
                .cacheControl(CacheControl.noStore())
                .body(service.movieCodeImage(claimId));
    }
    @Operation(summary = "查询权益可用次数", description = "按主卡或亲属附属卡、人才卡等级和商户配置查询当前可用权益次数,不生成核销码")
    @GetMapping("/quota")
    public ApiResponse<Map<String, Object>> quota(
            @Parameter(description = "权益类型:MOVIE_TICKET电影票、HOTEL酒店", required = true)
            @RequestParam String benefitType,
            @Parameter(description = "人才用户ID;未传cardId时必填")
            @RequestParam(required = false) Long talentId,
            @Parameter(description = "人才卡ID;支持主卡或亲属附属卡")
            @RequestParam(required = false) Long cardId,
            @Parameter(description = "商户ID", required = true)
            @RequestParam Long merchantId
    ) {
        return ApiResponse.success(service.quota(new BenefitVerificationCodeRequest(
                benefitType, talentId, cardId, merchantId)));
    }
    @Operation(summary = "生成权益核销码", description = "电影票生成固定兑换码,酒店生成60秒动态核销码")
    @PostMapping
    public ApiResponse<Map<String, Object>> create(
            @Valid @RequestBody BenefitVerificationCodeRequest request,
@@ -32,4 +64,4 @@
    ) {
        return ApiResponse.success(service.generate(request, idempotencyKey));
    }
}
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
@@ -8,6 +8,7 @@
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.service.BenefitQuotaService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,7 +16,11 @@
@Service
public class CinemaPortalService {
    private final JdbcTemplate jdbc;
    public CinemaPortalService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    private final BenefitQuotaService quotaService;
    public CinemaPortalService(JdbcTemplate jdbc, BenefitQuotaService quotaService) {
        this.jdbc = jdbc;
        this.quotaService = quotaService;
    }
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long merchantId) {
@@ -96,7 +101,8 @@
        if (claimId == null || talentId == null || talentId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "用户券参数不正确");
        List<Map<String, Object>> claims = query("""
                select cc.id as claim_id, cc.claim_no, cc.status as claim_status, cc.exchange_code, cc.code_status,
                       cc.valid_end, cc.used_at, c.coupon_name, c.annual_quota, c.usage_text, c.scope_text,
                       cc.valid_end, cc.used_at, cc.card_id, cc.talent_id, cc.merchant_id,
                       c.coupon_name, c.annual_quota, c.usage_text, c.scope_text,
                       m.merchant_name
                from cinema_coupon_claim cc
                join cinema_coupon c on c.id = cc.coupon_id and c.deleted = 0
@@ -105,13 +111,26 @@
                """, claimId, talentId);
        if (claims.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "用户券不存在或无权查看");
        Map<String, Object> claim = claims.get(0);
        int annualTotal = number(claim.get("annualQuota"));
        if (annualTotal < 1) annualTotal = 5;
        Long annualClaimed = jdbc.queryForObject("""
                select count(*) from cinema_coupon_claim
                where talent_id = ? and deleted = 0 and year(created_at) = year(current_date)
                """, Long.class, talentId);
        int claimedCount = annualClaimed == null ? 0 : annualClaimed.intValue();
        int annualTotal;
        int claimedCount;
        try {
            BenefitQuotaService.QuotaSnapshot quota = quotaService.current(
                    BenefitQuotaService.MOVIE_TICKET,
                    longValue(claim.get("cardId")),
                    longValue(claim.get("talentId")),
                    longValue(claim.get("merchantId")),
                    null);
            annualTotal = quota.total();
            claimedCount = quota.used();
        } catch (RuntimeException exception) {
            annualTotal = number(claim.get("annualQuota"));
            if (annualTotal < 1) annualTotal = 5;
            Long annualClaimed = jdbc.queryForObject("""
                    select count(*) from cinema_coupon_claim
                    where talent_id = ? and deleted = 0 and year(created_at) = year(current_date)
                    """, Long.class, talentId);
            claimedCount = annualClaimed == null ? 0 : annualClaimed.intValue();
        }
        String codeStatus = effectiveCodeStatus(claim);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("claimId", claim.get("claimId"));
@@ -134,7 +153,7 @@
        List<Map<String, Object>> merchants = query("""
                select id, merchant_name, merchant_type, contact_mobile, address, cover_url, service_label, rights_text,
                       latitude, longitude, status
                from merchant where id = ? and deleted = 0 and merchant_type = 'cinema'
                from merchant where id = ? and deleted = 0 and merchant_type in ('cinema', 'theatre', '电影院', '影院', '影城', '剧院')
                """, merchantId);
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "影城不存在");
        Map<String, Object> merchant = merchants.get(0);
@@ -215,6 +234,7 @@
        return configured.isBlank() || configured.contains(cardLevel);
    }
    private int number(Object value) { return value instanceof Number number ? number.intValue() : 0; }
    private Long longValue(Object value) { return value instanceof Number number ? number.longValue() : null; }
    private String text(Object value, String fallback) { return value == null || String.valueOf(value).isBlank() ? fallback : String.valueOf(value); }
    private List<Map<String, Object>> query(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -334,7 +334,7 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> entertainmentMerchants(int page, int size) {
        String merchantWhere = " where deleted = 0 and merchant_type in ('cinema', 'theatre')";
        String merchantWhere = " where deleted = 0 and merchant_type in ('cinema', 'theatre', '电影院')";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       service_label, status, sort_no, longitude, latitude, business_status,
@@ -376,6 +376,9 @@
            throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
        }
        String merchantWhere = " where deleted = 0 and merchant_type = ?";
        if (type.equals("parking")) {
            type = "停车服务";
        }
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       service_label, status, sort_no, longitude, latitude, business_status,
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java
@@ -12,6 +12,7 @@
    private Long id;
    private String merchantName;
    private String merchantType;
    private String merchantRightsConfig;
    private Integer status;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java
@@ -47,6 +47,21 @@
            """)
    TicketPackageCode selectAvailableCodeForUpdate(@Param("packageId") Long packageId);
    @Select("""
            select c.*
            from ticket_package_code c
            join ticket_package p on p.id = c.package_id and p.deleted = 0 and p.status = 1
            where c.deleted = 0
              and c.status = 'AVAILABLE'
              and p.merchant_id = #{merchantId}
              and (p.valid_start is null or p.valid_start <= current_date)
              and (p.valid_end is null or p.valid_end >= current_date)
            order by c.id asc
            limit 1
            for update
            """)
    TicketPackageCode selectAvailableByMerchantForUpdate(@Param("merchantId") Long merchantId);
    @Update("""
            update ticket_package_code
            set status = 'LOCKED',
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
@@ -6,6 +6,7 @@
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
@@ -14,6 +15,7 @@
import com.talentcard.server.entity.BenefitCinemaCouponClaim;
import com.talentcard.server.entity.BenefitFamilyCardApplication;
import com.talentcard.server.entity.BenefitFreeStayVoucher;
import com.talentcard.server.entity.BenefitMerchant;
import com.talentcard.server.entity.BenefitMerchantBenefitRule;
import com.talentcard.server.entity.BenefitMovieTicketRule;
import com.talentcard.server.entity.BenefitOrder;
@@ -23,14 +25,20 @@
import com.talentcard.server.mapper.BenefitFamilyCardApplicationMapper;
import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper;
import com.talentcard.server.mapper.BenefitMerchantBenefitRuleMapper;
import com.talentcard.server.mapper.BenefitMerchantMapper;
import com.talentcard.server.mapper.BenefitMovieTicketRuleMapper;
import com.talentcard.server.mapper.BenefitOrderMapper;
import com.talentcard.server.mapper.BenefitTalentCardMapper;
import com.talentcard.server.mapper.BenefitVerifyRecordMapper;
import org.springframework.stereotype.Service;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Service
public class BenefitQuotaService {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
    private static final TypeReference<List<Map<String, Object>>> RIGHTS_TYPE = new TypeReference<>() {};
    public static final String MOVIE_TICKET = "MOVIE_TICKET";
    public static final String HOTEL = "HOTEL";
@@ -41,6 +49,7 @@
    private final BenefitTalentCardMapper cardMapper;
    private final BenefitFamilyCardApplicationMapper familyMapper;
    private final BenefitMerchantMapper merchantMapper;
    private final BenefitMovieTicketRuleMapper movieRuleMapper;
    private final BenefitCinemaCouponClaimMapper claimMapper;
    private final BenefitFreeStayVoucherMapper voucherMapper;
@@ -51,6 +60,7 @@
    public BenefitQuotaService(
            BenefitTalentCardMapper cardMapper,
            BenefitFamilyCardApplicationMapper familyMapper,
            BenefitMerchantMapper merchantMapper,
            BenefitMovieTicketRuleMapper movieRuleMapper,
            BenefitCinemaCouponClaimMapper claimMapper,
            BenefitFreeStayVoucherMapper voucherMapper,
@@ -60,6 +70,7 @@
    ) {
        this.cardMapper = cardMapper;
        this.familyMapper = familyMapper;
        this.merchantMapper = merchantMapper;
        this.movieRuleMapper = movieRuleMapper;
        this.claimMapper = claimMapper;
        this.voucherMapper = voucherMapper;
@@ -92,7 +103,7 @@
        String benefitType = normalizeBenefitType(rawBenefitType);
        PrimaryCard primary = lockPrimaryCard(cardId, talentId);
        QuotaPolicy policy = policy(benefitType, primary, merchantId, productId);
        int used = usedQuota(benefitType, primary.sharedCardIds(), policy.windowStart());
        int used = usedQuota(benefitType, primary.sharedCardIds(), merchantId, policy.windowStart());
        return new QuotaSnapshot(
                benefitType,
                primary.sourceTalentId(),
@@ -139,7 +150,7 @@
            aliases.add("\u6770\u51fa\u4eba\u624d\u5361");
            aliases.add("\u6770\u51fa");
        }
        if (upper.contains("ELITE") || raw.contains("\u83c1\u82f1")) {
        if (upper.contains("ELITE") || raw.contains("\u83c1\u82f1") || raw.contains("\u7cbe\u82f1")) {
            aliases.add("ELITE");
            aliases.add("\u83c1\u82f1\u4eba\u624d\u5361");
            aliases.add("\u83c1\u82f1");
@@ -301,6 +312,11 @@
    private QuotaPolicy hotelPolicy(PrimaryCard primary, Long merchantId, Long productId) {
        if (merchantId != null) {
            QuotaPolicy merchantRightsPolicy = merchantRightsPolicy(primary, merchantId);
            if (merchantRightsPolicy != null) {
                return merchantRightsPolicy;
            }
            List<String> aliases = cardLevelAliases(primary.cardLevel());
            QueryWrapper<BenefitMerchantBenefitRule> query = new QueryWrapper<BenefitMerchantBenefitRule>()
                    .eq("deleted", 0)
@@ -340,11 +356,102 @@
        return new QuotaPolicy(configured, "YEARLY", windowStart("YEARLY", false));
    }
    private int usedQuota(String benefitType, List<Long> sharedCardIds, LocalDateTime windowStart) {
    private QuotaPolicy merchantRightsPolicy(PrimaryCard primary, Long merchantId) {
        BenefitMerchant merchant = merchantMapper.selectById(merchantId);
        if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1
                || !isHotelMerchantType(merchant.getMerchantType())) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效酒店商户");
        }
        String config = text(merchant.getMerchantRightsConfig());
        if (config.isBlank()) {
            return null;
        }
        Map<String, Object> matched = null;
        String cardLevel = normalizeCardLevel(primary.cardLevel());
        try {
            for (Map<String, Object> right : JSON_MAPPER.readValue(config, RIGHTS_TYPE)) {
                if (cardLevel.equals(normalizeCardLevel(right.get("level")))) {
                    matched = right;
                    break;
                }
            }
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "酒店商户权益配置格式不正确");
        }
        if (matched == null) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未配置酒店权益");
        }
        if (!booleanValue(matched.get("enabled"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未启用酒店权益");
        }
        Object countValue = firstValue(matched, "freeCount", "freeLimitCount", "limitCount");
        Object freeValue = firstValue(matched, "freeEnabled", "free");
        if (freeValue != null && !booleanValue(freeValue)) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未启用酒店免费券");
        }
        int total = intValue(countValue);
        if (total < 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未配置酒店免费券次数");
        }
        Object familyShared = firstValue(matched, "familyCardShared", "canFamilyUse", "familyUseEnabled");
        if (primary.familyCard() && familyShared != null && !booleanValue(familyShared)) {
            throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡不可共享该酒店权益");
        }
        String resetCycle = text(firstValue(matched, "freeResetCycle", "resetCycle"));
        if (resetCycle.isBlank()) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未配置酒店免费券重置周期");
        }
        return new QuotaPolicy(total, resetCycle, windowStart(resetCycle, false));
    }
    private Object firstValue(Map<String, Object> values, String... keys) {
        for (String key : keys) {
            Object value = values.get(key);
            if (value != null && !text(value).isBlank()) {
                return value;
            }
        }
        return null;
    }
    private String normalizeCardLevel(Object value) {
        String raw = text(value);
        String upper = raw.toUpperCase(Locale.ROOT);
        if (upper.contains("EXCELLENT") || raw.contains("\u5353\u8d8a")) {
            return "EXCELLENT";
        }
        if (upper.contains("OUTSTANDING") || raw.contains("\u6770\u51fa")) {
            return "OUTSTANDING";
        }
        if (upper.contains("ELITE") || raw.contains("\u83c1\u82f1") || raw.contains("\u7cbe\u82f1")) {
            return "ELITE";
        }
        return upper;
    }
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) {
            return bool;
        }
        if (value instanceof Number number) {
            return number.intValue() != 0;
        }
        String raw = text(value);
        return "true".equalsIgnoreCase(raw) || "1".equals(raw) || "yes".equalsIgnoreCase(raw);
    }
    private boolean isHotelMerchantType(String merchantType) {
        String type = text(merchantType);
        return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type);
    }
    private int usedQuota(String benefitType, List<Long> sharedCardIds, Long merchantId, LocalDateTime windowStart) {
        if (MOVIE_TICKET.equals(benefitType)) {
            return usedMovieQuota(sharedCardIds, windowStart);
        }
        return usedHotelQuota(sharedCardIds, windowStart);
        return usedHotelQuota(sharedCardIds, merchantId, windowStart);
    }
    private int usedMovieQuota(List<Long> sharedCardIds, LocalDateTime windowStart) {
@@ -352,33 +459,43 @@
                .eq("deleted", 0)
                .in("card_id", sharedCardIds)
                .apply("((upper(coalesce(code_status, 'ACTIVE')) = 'USED' and used_at >= {0}) or "
                        + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= {1} "
                        + "and updated_at > date_sub(now(), interval 300 second)))", windowStart, windowStart));
                        + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= {1}))", windowStart, windowStart));
        return intValue(used);
    }
    private int usedHotelQuota(List<Long> sharedCardIds, LocalDateTime windowStart) {
        Long records = verifyRecordMapper.selectCount(new QueryWrapper<BenefitVerifyRecord>()
    private int usedHotelQuota(List<Long> sharedCardIds, Long merchantId, LocalDateTime windowStart) {
        QueryWrapper<BenefitVerifyRecord> recordQuery = new QueryWrapper<BenefitVerifyRecord>()
                .in("card_id", sharedCardIds)
                .eq("status", 1)
                .ge("verify_time", windowStart)
                .and(wrapper -> wrapper.in("verify_type", HOTEL_VERIFY_TYPES)
                        .or()
                        .apply("exists (select 1 from benefit_order o "
                                + "join merchant m on m.id = o.merchant_id and m.deleted = 0 and m.merchant_type = 'hotel' "
                                + "where o.id = verify_record.order_id and o.deleted = 0)")));
                                + "join merchant m on m.id = o.merchant_id and m.deleted = 0 "
                                + "and m.merchant_type in ('hotel', '酒店服务') "
                                + "where o.id = verify_record.order_id and o.deleted = 0)"));
        if (merchantId != null) {
            recordQuery.and(wrapper -> wrapper.eq("merchant_id", merchantId)
                    .or()
                    .apply("exists (select 1 from benefit_order o where o.id = verify_record.order_id "
                            + "and o.deleted = 0 and o.merchant_id = {0})", merchantId));
        }
        Long records = verifyRecordMapper.selectCount(recordQuery);
        Long ordersWithoutRecord = orderMapper.selectCount(new QueryWrapper<BenefitOrder>()
        QueryWrapper<BenefitOrder> orderQuery = new QueryWrapper<BenefitOrder>()
                .eq("deleted", 0)
                .in("card_id", sharedCardIds)
                .eq("verify_status", 1)
                .ge("updated_at", windowStart)
                .apply("exists (select 1 from merchant m where m.id = benefit_order.merchant_id "
                        + "and m.deleted = 0 and m.merchant_type = 'hotel')")
                .apply("not exists (select 1 from verify_record r where r.order_id = benefit_order.id and r.status = 1)"));
                        + "and m.deleted = 0 and m.merchant_type in ('hotel', '酒店服务'))")
                .apply("not exists (select 1 from verify_record r where r.order_id = benefit_order.id and r.status = 1)");
        if (merchantId != null) {
            orderQuery.eq("merchant_id", merchantId);
        }
        Long ordersWithoutRecord = orderMapper.selectCount(orderQuery);
        return intValue(records) + intValue(ordersWithoutRecord);
    }
    private LocalDateTime windowStart(String resetCycle, boolean monthlyDefault) {
        String cycle = resetCycle == null ? "" : resetCycle.trim().toUpperCase(Locale.ROOT);
        LocalDate today = LocalDate.now();
@@ -417,7 +534,15 @@
    }
    private int intValue(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
        if (value instanceof Number number) {
            return number.intValue();
        }
        try {
            String raw = text(value);
            return raw.isBlank() ? 0 : Integer.parseInt(raw);
        } catch (NumberFormatException ignored) {
            return 0;
        }
    }
    private record PrimaryCard(
@@ -456,4 +581,4 @@
            List<Long> sharedCardIds
    ) {
    }
}
}
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
@@ -1,5 +1,11 @@
package com.talentcard.server.service;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import javax.imageio.ImageIO;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.qrcode.QRCodeWriter;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -20,18 +26,21 @@
import com.talentcard.server.entity.BenefitFreeStayVerificationCode;
import com.talentcard.server.entity.BenefitFreeStayVoucher;
import com.talentcard.server.entity.BenefitMerchant;
import com.talentcard.server.entity.TicketPackageCode;
import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper;
import com.talentcard.server.mapper.BenefitCinemaCouponMapper;
import com.talentcard.server.mapper.BenefitFreeStayVerificationCodeMapper;
import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper;
import com.talentcard.server.mapper.BenefitMerchantMapper;
import com.talentcard.server.mapper.TicketPackageCodeMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BenefitVerificationCodeService {
    public static final int CODE_SECONDS = 300;
    public static final String MOVIE_TOKEN_PREFIX = "MV2";
    public static final int MOVIE_CODE_SECONDS = 0;
    public static final int HOTEL_CODE_SECONDS = 60;
    public static final String MOVIE_TOKEN_PREFIX = "WD-";
    public static final String HOTEL_TOKEN_PREFIX = "HV2";
    private final BenefitQuotaService quotaService;
@@ -40,10 +49,12 @@
    private final BenefitCinemaCouponClaimMapper claimMapper;
    private final BenefitFreeStayVoucherMapper voucherMapper;
    private final BenefitFreeStayVerificationCodeMapper freeStayCodeMapper;
    private final TicketPackageCodeMapper codeMapper;
    public BenefitVerificationCodeService(
            BenefitQuotaService quotaService,
            BenefitMerchantMapper merchantMapper,
            TicketPackageCodeMapper codeMapper,
            BenefitCinemaCouponMapper couponMapper,
            BenefitCinemaCouponClaimMapper claimMapper,
            BenefitFreeStayVoucherMapper voucherMapper,
@@ -51,10 +62,59 @@
    ) {
        this.quotaService = quotaService;
        this.merchantMapper = merchantMapper;
        this.codeMapper = codeMapper;
        this.couponMapper = couponMapper;
        this.claimMapper = claimMapper;
        this.voucherMapper = voucherMapper;
        this.freeStayCodeMapper = freeStayCodeMapper;
    }
    @Transactional(readOnly = true)
    public byte[] movieCodeImage(Long claimId) {
        BenefitCinemaCouponClaim claim = movieClaimById(claimId);
        if (!activeMovieCode(claim)) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票兑换码不可用");
        }
        try {
            var matrix = new QRCodeWriter().encode(claim.getExchangeCode(), BarcodeFormat.QR_CODE, 480, 480);
            BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix);
            ByteArrayOutputStream output = new ByteArrayOutputStream();
            ImageIO.write(image, "PNG", output);
            return output.toByteArray();
        } catch (Exception ex) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "电影票二维码生成失败");
        }
    }
    @Transactional
    public Map<String, Object> quota(BenefitVerificationCodeRequest request) {
        Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空");
        String benefitType = quotaService.normalizeBenefitType(request.benefitType());
        if (BenefitQuotaService.MOVIE_TICKET.equals(benefitType)) {
            ensureCinemaMerchant(merchantId);
        } else {
            ensureHotelMerchant(merchantId);
        }
        BenefitQuotaService.QuotaSnapshot quota = quotaService.current(
                benefitType, request.cardId(), request.talentId(), merchantId, null);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("benefitType", benefitType);
        result.put("merchantId", merchantId);
        result.put("available", quota.remaining() > 0);
        result.put("quotaTotal", quota.total());
        result.put("quotaUsed", quota.used());
        result.put("quotaRemaining", quota.remaining());
        result.put("quotaResetCycle", quota.resetCycle());
        result.put("quotaWindowStart", quota.windowStart());
        result.put("sourceCardId", quota.cardId());
        result.put("sourceTalentId", quota.talentId());
        result.put("sourceCardType", quota.cardType());
        result.put("familyCard", quota.familyCard());
        result.put("primaryCardId", quota.primaryCardId());
        result.put("primaryTalentId", quota.primaryTalentId());
        result.put("primaryCardNo", quota.primaryCardNo());
        result.put("cardLevel", quota.cardLevel());
        return result;
    }
    @Transactional
@@ -68,7 +128,6 @@
    private Map<String, Object> generateMovieCode(BenefitVerificationCodeRequest request, String idempotencyKey) {
        Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空");
        expireExpiredMovieCodes(merchantId);
        BenefitQuotaService.QuotaSnapshot quota = quotaService.requireAvailable(
                BenefitQuotaService.MOVIE_TICKET, request.cardId(), request.talentId(), merchantId, null, 1);
@@ -78,39 +137,40 @@
        int dailyLimit = choice.dailyLimit();
        int dailyUsed = choice.dailyUsed();
        String token = MOVIE_TOKEN_PREFIX + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
        String claimKey = claimIdempotencyKey(idempotencyKey, token);
        String claimKey = claimIdempotencyKey(idempotencyKey, "MOVIE");
        BenefitCinemaCouponClaim idempotent = movieClaimByIdempotencyKey(claimKey);
        if (idempotent != null) {
            ensureSameMovieClaim(idempotent, couponId, merchantId, quota.cardId());
            if (activeMovieCode(idempotent) && startsWithMoviePrefix(idempotent.getExchangeCode())) {
            if (activeMovieCode(idempotent)) {
                return movieCodeResponse(idempotent, coupon, quota, dailyLimit, dailyUsed, false);
            }
            throw new BusinessException(ErrorCode.CONFLICT, "幂等键对应的电影票核销码已过期或已使用");
        }
        BenefitCinemaCouponClaim existing = movieClaimForUpdate(couponId, quota.talentId());
        Long claimId;
        if (existing != null) {
            String status = upper(existing.getCodeStatus());
            if ("USED".equals(status)) {
                throw new BusinessException(ErrorCode.CONFLICT, "电影票已核销");
            }
            if (activeMovieCode(existing) && startsWithMoviePrefix(existing.getExchangeCode())) {
            if (activeMovieCode(existing)) {
                return movieCodeResponse(existing, coupon, quota, dailyLimit, dailyUsed, false);
            }
            boolean stockAlreadyReserved = "ACTIVE".equals(status) && !startsWithMoviePrefix(existing.getExchangeCode());
            if (!stockAlreadyReserved) {
                decrementMovieStock(couponId, merchantId);
            }
        }
        TicketPackageCode poolCode = reserveMoviePoolCode(merchantId, quota.talentId(), quota.cardId());
        Long claimId;
        if (existing != null) {
            decrementMovieStock(couponId, merchantId);
            BenefitCinemaCouponClaim update = new BenefitCinemaCouponClaim();
            update.setId(existing.getId());
            update.setExchangeCode(token);
            update.setExchangeCode(poolCode.getExchangeCode());
            update.setCodeStatus("ACTIVE");
            update.setStatus("RECEIVED");
            update.setUsedAt(null);
            update.setValidEnd(coupon.getValidEnd());
            update.setIdempotencyKey(claimKey);
            update.setDeleted(0);
            claimMapper.updateById(update);
            claimId = existing.getId();
        } else {
@@ -124,13 +184,14 @@
            claim.setCardNo(quota.cardNo());
            claim.setValidEnd(coupon.getValidEnd());
            claim.setStatus("RECEIVED");
            claim.setExchangeCode(token);
            claim.setExchangeCode(poolCode.getExchangeCode());
            claim.setCodeStatus("ACTIVE");
            claim.setIdempotencyKey(claimKey);
            claim.setDeleted(0);
            claimMapper.insert(claim);
            claimId = claim.getId();
        }
        issueMoviePoolCode(poolCode, claimId);
        BenefitCinemaCouponClaim claim = movieClaimById(claimId);
        return movieCodeResponse(claim, couponMapper.selectById(couponId), quota, dailyLimit, dailyUsed + 1, true);
    }
@@ -140,7 +201,7 @@
        ensureHotelMerchant(merchantId);
        BenefitQuotaService.QuotaSnapshot quota = quotaService.requireAvailable(
                BenefitQuotaService.HOTEL, request.cardId(), request.talentId(), merchantId, null, 1);
        BenefitFreeStayVoucher voucher = voucherForUpdate(quota.primaryTalentId(), merchantId, quota.primaryCardId(), quota.total());
        BenefitFreeStayVoucher voucher = voucherForUpdate(quota, merchantId);
        if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店权益券当前不可用");
        }
@@ -155,7 +216,7 @@
        BenefitFreeStayVerificationCode code = new BenefitFreeStayVerificationCode();
        code.setCodeToken(hotelFreeStayToken(quota.cardId()));
        code.setVoucherId(voucher.getId());
        code.setExpiresAt(LocalDateTime.now().plusSeconds(CODE_SECONDS));
        code.setExpiresAt(LocalDateTime.now().plusSeconds(HOTEL_CODE_SECONDS));
        code.setStatus(0);
        freeStayCodeMapper.insert(code);
        return hotelCodeResponse(code, voucher, quota);
@@ -193,6 +254,27 @@
        throw new BusinessException(ErrorCode.CONFLICT, "当天电影票领取次数已达上限");
    }
    private TicketPackageCode reserveMoviePoolCode(Long merchantId, Long talentId, Long cardId) {
        TicketPackageCode code = codeMapper.selectAvailableByMerchantForUpdate(merchantId);
        if (code == null) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票兑换码码池库存不足,请联系后台导入兑换码");
        }
        int locked = codeMapper.lockForIssue(code.getId(), talentId, cardId);
        if (locked != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票兑换码已被领取,请重试");
        }
        return code;
    }
    private void issueMoviePoolCode(TicketPackageCode code, Long claimId) {
        codeMapper.update(null, new UpdateWrapper<TicketPackageCode>()
                .set("status", "ISSUED")
                .set("issued_claim_id", claimId)
                .set("issued_at", LocalDateTime.now())
                .eq("id", code.getId())
                .eq("status", "LOCKED")
                .eq("deleted", 0));
    }
    private void decrementMovieStock(Long couponId, Long merchantId) {
        int updated = couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
                .setSql("stock = stock - 1")
@@ -213,35 +295,9 @@
                .eq("coupon_id", couponId)
                .apply("((upper(coalesce(code_status, 'ACTIVE')) = 'USED' and used_at >= current_date) or "
                        + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= current_date "
                        + "and updated_at > date_sub(now(), interval 300 second)))"));
                        + ")))"));
        return intValue(count);
    }
    private void expireExpiredMovieCodes(Long merchantId) {
        List<BenefitCinemaCouponClaim> expired = claimMapper.selectList(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("merchant_id", merchantId)
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'")
                .likeRight("exchange_code", MOVIE_TOKEN_PREFIX)
                .isNull("used_at")
                .apply("updated_at <= date_sub(now(), interval 300 second)")
                .last("for update"));
        for (BenefitCinemaCouponClaim claim : expired) {
            int updated = claimMapper.update(null, new UpdateWrapper<BenefitCinemaCouponClaim>()
                    .set("code_status", "EXPIRED")
                    .set("status", "EXPIRED")
                    .eq("id", claim.getId())
                    .eq("deleted", 0)
                    .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'"));
            if (updated == 1) {
                couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
                        .setSql("stock = stock + 1")
                        .eq("id", claim.getCouponId())
                        .eq("deleted", 0));
            }
        }
    }
    private BenefitCinemaCouponClaim movieClaimByIdempotencyKey(String key) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
@@ -250,9 +306,7 @@
    }
    private BenefitCinemaCouponClaim movieClaimForUpdate(Long couponId, Long talentId) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("coupon_id", couponId)
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>().eq("coupon_id", couponId)
                .eq("talent_id", talentId)
                .last("limit 1 for update"));
    }
@@ -277,56 +331,63 @@
    private void ensureCinemaMerchant(Long merchantId) {
        BenefitMerchant merchant = merchantMapper.selectById(merchantId);
        if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1
                || !"cinema".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
                || !isCinemaMerchantType(merchant.getMerchantType())) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效影院商户");
        }
    }
    private boolean isCinemaMerchantType(String merchantType) {
        String type = merchantType == null ? "" : merchantType.trim();
        return "cinema".equalsIgnoreCase(type) || "theatre".equalsIgnoreCase(type)
                || "电影院".equals(type) || "影院".equals(type) || "影城".equals(type) || "剧院".equals(type);
    }
    private void ensureHotelMerchant(Long merchantId) {
        BenefitMerchant merchant = merchantMapper.selectById(merchantId);
        if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1
                || !"hotel".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
                || !isHotelMerchantType(merchant.getMerchantType())) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效酒店商户");
        }
    }
    private BenefitFreeStayVoucher voucherForUpdate(Long primaryTalentId, Long merchantId, Long primaryCardId, int totalCount) {
    private boolean isHotelMerchantType(String merchantType) {
        String type = merchantType == null ? "" : merchantType.trim();
        return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type);
    }
    private BenefitFreeStayVoucher voucherForUpdate(BenefitQuotaService.QuotaSnapshot quota, Long merchantId) {
        BenefitFreeStayVoucher voucher = voucherMapper.selectOne(new QueryWrapper<BenefitFreeStayVoucher>()
                .eq("talent_id", primaryTalentId)
                .eq("talent_id", quota.primaryTalentId())
                .eq("merchant_id", merchantId)
                .last("limit 1 for update"));
        LocalDate validEnd = LocalDate.now().plusYears(1);
        if (voucher == null) {
            voucher = new BenefitFreeStayVoucher();
            voucher.setVoucherNo(BusinessIdGenerator.next("FSV"));
            voucher.setTalentId(primaryTalentId);
            voucher.setTalentId(quota.primaryTalentId());
            voucher.setMerchantId(merchantId);
            voucher.setCardId(primaryCardId);
            voucher.setTotalCount(totalCount);
            voucher.setUsedCount(0);
            voucher.setCardId(quota.primaryCardId());
            voucher.setTotalCount(quota.total());
            voucher.setUsedCount(quota.used());
            voucher.setValidEnd(validEnd);
            voucher.setStatus(1);
            voucherMapper.insert(voucher);
            return voucher;
        }
        if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())) {
            voucher.setTotalCount(totalCount);
            voucher.setUsedCount(0);
            voucher.setValidEnd(validEnd);
            voucher.setStatus(1);
            voucherMapper.updateById(voucher);
        } else if (intValue(voucher.getTotalCount()) < totalCount) {
            voucher.setTotalCount(totalCount);
            voucherMapper.updateById(voucher);
        }
        voucher.setCardId(quota.primaryCardId());
        voucher.setTotalCount(quota.total());
        voucher.setUsedCount(quota.used());
        voucher.setValidEnd(validEnd);
        voucher.setStatus(1);
        voucherMapper.updateById(voucher);
        return voucher;
    }
    private Map<String, Object> movieCodeResponse(BenefitCinemaCouponClaim claim, BenefitCinemaCoupon coupon,
                                                  BenefitQuotaService.QuotaSnapshot quota,
                                                  int dailyLimit, int dailyUsed, boolean newlyReserved) {
        LocalDateTime updatedAt = claim.getUpdatedAt() == null ? LocalDateTime.now() : claim.getUpdatedAt();
        LocalDateTime expiresAt = updatedAt.plusSeconds(CODE_SECONDS);
        LocalDateTime expiresAt = coupon != null && coupon.getValidEnd() != null
                ? coupon.getValidEnd().plusDays(1).atStartOfDay()
                : LocalDateTime.now().plusYears(1);
        BenefitMerchant merchant = merchantMapper.selectById(claim.getMerchantId());
        Map<String, Object> result = baseResponse(BenefitQuotaService.MOVIE_TICKET, claim.getExchangeCode(), expiresAt, quota);
        result.put("displayCode", claim.getExchangeCode());
@@ -367,7 +428,8 @@
        result.put("expiresAt", expiresAt);
        result.put("expiresAtEpochMillis", expiresAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
        result.put("expiresInSeconds", Math.max(0, java.time.Duration.between(LocalDateTime.now(), expiresAt).toSeconds()));
        result.put("refreshAfterSeconds", CODE_SECONDS);
        result.put("refreshAfterSeconds", BenefitQuotaService.HOTEL.equals(benefitType)
                ? HOTEL_CODE_SECONDS : 0);
        result.put("sharedQuotaMode", "AUTO_DEDUCT");
        result.put("sourceCardId", quota.cardId());
        result.put("sourceTalentId", quota.talentId());
@@ -391,11 +453,7 @@
    }
    private boolean activeMovieCode(BenefitCinemaCouponClaim claim) {
        return "ACTIVE".equals(upper(claim.getCodeStatus())) && !movieCodeExpired(claim.getUpdatedAt());
    }
    private boolean movieCodeExpired(LocalDateTime updatedAt) {
        return updatedAt != null && !updatedAt.plusSeconds(CODE_SECONDS).isAfter(LocalDateTime.now());
        return "ACTIVE".equals(upper(claim.getCodeStatus())) && !expiredDate(claim.getValidEnd());
    }
    private boolean startsWithMoviePrefix(String code) {
@@ -454,4 +512,4 @@
    private record MovieCouponChoice(BenefitCinemaCoupon coupon, int dailyLimit, int dailyUsed) {
    }
}
}
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java
@@ -100,10 +100,6 @@
        if (!verifyCode.startsWith(BenefitVerificationCodeService.MOVIE_TOKEN_PREFIX)) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码必须由二期生成核销码接口生成");
        }
        if (movieCodeExpired(probe.getUpdatedAt())) {
            expireMovieClaim(probe.getId());
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已过期");
        }
        quotaService.current(BenefitQuotaService.MOVIE_TICKET, probe.getCardId(), probe.getTalentId(), probe.getMerchantId(), null);
        BenefitCinemaCouponClaim claim = movieClaimByCode(verifyCode, true);
@@ -117,8 +113,7 @@
                .eq("id", claim.getId())
                .eq("deleted", 0)
                .eq("exchange_code", verifyCode)
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'")
                .apply("updated_at > date_sub(now(), interval 300 second)"));
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'"));
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已使用或已过期");
        }
@@ -231,40 +226,13 @@
            throw new BusinessException(ErrorCode.NOT_FOUND, "电影票核销码不存在");
        }
        BenefitCinemaCoupon coupon = couponMapper.selectById(claim.getCouponId());
        if (!"ACTIVE".equals(upper(claim.getCodeStatus())) || movieCodeExpired(claim.getUpdatedAt())) {
        if (!"ACTIVE".equals(upper(claim.getCodeStatus()))) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已过期");
        }
        if (coupon == null || expiredDate(claim.getValidEnd()) || intValue(coupon.getStatus()) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票权益券当前不可用");
        }
    }
    private void expireMovieClaim(Long claimId) {
        BenefitCinemaCouponClaim claim = claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("id", claimId)
                .eq("deleted", 0)
                .last("limit 1 for update"));
        if (claim == null) {
            return;
        }
        if ("ACTIVE".equals(upper(claim.getCodeStatus()))
                && startsWithMoviePrefix(claim.getExchangeCode())
                && movieCodeExpired(claim.getUpdatedAt())) {
            int updated = claimMapper.update(null, new UpdateWrapper<BenefitCinemaCouponClaim>()
                    .set("code_status", "EXPIRED")
                    .set("status", "EXPIRED")
                    .eq("id", claimId)
                    .eq("deleted", 0)
                    .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'"));
            if (updated == 1) {
                couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
                        .setSql("stock = stock + 1")
                        .eq("id", claim.getCouponId())
                        .eq("deleted", 0));
            }
        }
    }
    private BenefitCinemaCouponClaim movieClaimByCode(String code, boolean lock) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
@@ -441,10 +409,6 @@
        return parsed > 0 ? parsed : null;
    }
    private boolean movieCodeExpired(LocalDateTime updatedAt) {
        return updatedAt != null && !updatedAt.plusSeconds(BenefitVerificationCodeService.CODE_SECONDS).isAfter(LocalDateTime.now());
    }
    private boolean startsWithMoviePrefix(String code) {
        return code != null && code.startsWith(BenefitVerificationCodeService.MOVIE_TOKEN_PREFIX);
    }
@@ -505,4 +469,4 @@
    private record FreeStayCodeContext(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher) {
    }
}
}
talent-card-server/src/main/resources/db/migration/V93__merchant_onboarding_huifu_kyc.sql
File was deleted
talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql
@@ -1,2 +1,15 @@
alter table merchant_coupon
    add column talent_level_quantities varchar(512) default null comment 'talent card level quantities JSON';
-- 先检查列是否存在,如果不存在则添加
SET @dbname = DATABASE();
SET @tablename = 'merchant_coupon';
SET @columnname = 'talent_level_quantities';
SET @preparedStatement = (SELECT IF(
    (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
     WHERE TABLE_SCHEMA = @dbname
     AND TABLE_NAME = @tablename
     AND COLUMN_NAME = @columnname) = 0,
    CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(512) DEFAULT NULL COMMENT ''talent card level quantities JSON'';'),
    'SELECT 1;'
));
PREPARE stmt FROM @preparedStatement;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql
New file
@@ -0,0 +1,57 @@
-- V99__merchant_onboarding_huifu_kyc.sql
-- 兼容 MySQL 5.7 及以下版本的幂等列添加
DELIMITER $$
DROP PROCEDURE IF EXISTS add_columns_merchant_onboarding$$
CREATE PROCEDURE add_columns_merchant_onboarding()
BEGIN
    DECLARE col_exists INT DEFAULT 0;
    -- 检查并添加 subject_id_no_encrypted
SELECT COUNT(*) INTO col_exists
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'merchant_onboarding_application'
  AND COLUMN_NAME = 'subject_id_no_encrypted';
IF col_exists = 0 THEN
ALTER TABLE merchant_onboarding_application
    ADD COLUMN subject_id_no_encrypted VARCHAR(512) DEFAULT NULL
    AFTER subject_id_no_masked;
END IF;
    -- 检查并添加 subject_cert_validity_type
SELECT COUNT(*) INTO col_exists
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'merchant_onboarding_application'
  AND COLUMN_NAME = 'subject_cert_validity_type';
IF col_exists = 0 THEN
ALTER TABLE merchant_onboarding_application
    ADD COLUMN subject_cert_validity_type VARCHAR(8) DEFAULT NULL
    AFTER subject_id_no_encrypted;
END IF;
    -- 检查并添加 subject_cert_begin_date
SELECT COUNT(*) INTO col_exists
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'merchant_onboarding_application'
  AND COLUMN_NAME = 'subject_cert_begin_date';
IF col_exists = 0 THEN
ALTER TABLE merchant_onboarding_application
    ADD COLUMN subject_cert_begin_date CHAR(8) DEFAULT NULL
    AFTER subject_cert_validity_type;
END IF;
END$$
DELIMITER ;
-- 执行存储过程
CALL add_columns_merchant_onboarding();
-- 清理存储过程
DROP PROCEDURE IF EXISTS add_columns_merchant_onboarding;