From 8898f00c2150048a03ca561b7ed98dbf9c80732b Mon Sep 17 00:00:00 2001
From: wenminjie <1036699586@qq.com>
Date: 星期四, 06 八月 2026 16:18:51 +0800
Subject: [PATCH] flyway、对接小程序

---
 rencaika/config/api.uts                                                                                       |    3 
 talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java            |  206 ++++++++----
 talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java                            |    1 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java |   38 ++
 rencaika/pages/coupon-received/coupon-received.uvue                                                           |   42 --
 rencaika/pages/cinema-merchant/cinema-merchant.uvue                                                           |   13 
 rencaika/pages/merchant/merchant.uvue                                                                         |   11 
 rencaika/pages.json                                                                                           |   14 
 rencaika/pages/free-benefit/free-benefit.uvue                                                                 |  144 +++++++-
 /dev/null                                                                                                     |    3 
 rencaika/pages/shopping-benefits/shopping-benefits.uvue                                                       |    5 
 talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java                    |   15 +
 talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java                     |    5 
 talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql                     |   57 +++
 rencaika/services/portal.uts                                                                                  |   49 +++
 talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java               |   40 ++
 talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java                |   42 --
 talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java                       |  157 +++++++++-
 rencaika/pages/entertainment-benefits/entertainment-benefits.uvue                                             |   12 
 talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql           |   17 +
 20 files changed, 654 insertions(+), 220 deletions(-)

diff --git a/rencaika/config/api.uts b/rencaika/config/api.uts
index ddaf402..dfa4c10 100644
--- a/rencaika/config/api.uts
+++ b/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'
diff --git a/rencaika/pages.json b/rencaika/pages.json
index 2b1b08a..07d08b0 100644
--- a/rencaika/pages.json
+++ b/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": {
diff --git a/rencaika/pages/cinema-merchant/cinema-merchant.uvue b/rencaika/pages/cinema-merchant/cinema-merchant.uvue
index 3437a18..734fa19 100644
--- a/rencaika/pages/cinema-merchant/cinema-merchant.uvue
+++ b/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>
 
diff --git a/rencaika/pages/coupon-received/coupon-received.uvue b/rencaika/pages/coupon-received/coupon-received.uvue
index 79ae634..40012c9 100644
--- a/rencaika/pages/coupon-received/coupon-received.uvue
+++ b/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>
diff --git a/rencaika/pages/entertainment-benefits/entertainment-benefits.uvue b/rencaika/pages/entertainment-benefits/entertainment-benefits.uvue
index c776994..eab744a 100644
--- a/rencaika/pages/entertainment-benefits/entertainment-benefits.uvue
+++ b/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>
diff --git a/rencaika/pages/free-benefit/free-benefit.uvue b/rencaika/pages/free-benefit/free-benefit.uvue
index bbf9c0d..f9fc4bd 100644
--- a/rencaika/pages/free-benefit/free-benefit.uvue
+++ b/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; }
diff --git a/rencaika/pages/merchant/merchant.uvue b/rencaika/pages/merchant/merchant.uvue
index 66373ab..ae5abd8 100644
--- a/rencaika/pages/merchant/merchant.uvue
+++ b/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,
diff --git a/rencaika/pages/shopping-benefits/shopping-benefits.uvue b/rencaika/pages/shopping-benefits/shopping-benefits.uvue
index 4bfbda6..d8d887f 100644
--- a/rencaika/pages/shopping-benefits/shopping-benefits.uvue
+++ b/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>
diff --git a/rencaika/services/portal.uts b/rencaika/services/portal.uts
index cbbec3f..0e04797 100644
--- a/rencaika/services/portal.uts
+++ b/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>({
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java
index ba864ab..bb68a0a 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java
+++ b/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));
     }
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
index bf29410..3670aee 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
+++ b/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)); }
 }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
index b109211..a002cdc 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
+++ b/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,
diff --git a/talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java b/talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java
index d0b714d..8204f9f 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java
+++ b/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;
 }
\ No newline at end of file
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java
index 1354569..f5be65e 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java
+++ b/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',
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java b/talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
index fa8f4b3..32be0ae 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
+++ b/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
     ) {
     }
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java b/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
index a229c44..ee19fa8 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
+++ b/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) {
     }
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java b/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java
index d9d7ee7..0493eb4 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java
+++ b/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) {
     }
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/main/resources/db/migration/V93__merchant_onboarding_huifu_kyc.sql b/talent-card-server/src/main/resources/db/migration/V93__merchant_onboarding_huifu_kyc.sql
deleted file mode 100644
index d47c175..0000000
--- a/talent-card-server/src/main/resources/db/migration/V93__merchant_onboarding_huifu_kyc.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-alter table merchant_onboarding_application add column subject_id_no_encrypted varchar(512) default null after subject_id_no_masked;
-alter table merchant_onboarding_application add column subject_cert_validity_type varchar(8) default null after subject_id_no_encrypted;
-alter table merchant_onboarding_application add column subject_cert_begin_date char(8) default null after subject_cert_validity_type;
diff --git a/talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql b/talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql
index cd961da..7056cf4 100644
--- a/talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql
+++ b/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;
\ No newline at end of file
diff --git a/talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql b/talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql
new file mode 100644
index 0000000..2bfcedd
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql
@@ -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;
\ No newline at end of file

--
Gitblit v1.7.1