| | |
| | | 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' |
| | |
| | | "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": { |
| | |
| | | <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> |
| | |
| | | |
| | | <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' |
| | |
| | | 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) => { |
| | |
| | | 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 |
| | |
| | | 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> |
| | | |
| | |
| | | <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> |
| | |
| | | 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> |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | 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> |
| | |
| | | .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> |
| | |
| | | <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> |
| | |
| | | </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' |
| | |
| | | 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 |
| | |
| | | // #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' |
| | |
| | | |
| | | 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> |
| | | |
| | |
| | | .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; } |
| | |
| | | |
| | | <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' |
| | |
| | | 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 |
| | |
| | | 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, |
| | |
| | | <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> |
| | |
| | | }, () => { 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> |
| | |
| | | .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> |
| | |
| | | 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) => { |
| | |
| | | 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>({ |
| | |
| | | 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>({ |
| | |
| | | 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 { |
| | |
| | | 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, |
| | |
| | | ) { |
| | | return ApiResponse.success(service.generate(request, idempotencyKey)); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | @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) { |
| | |
| | | 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 |
| | |
| | | """, 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")); |
| | |
| | | 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); |
| | |
| | | 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)); } |
| | | } |
| | |
| | | |
| | | @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, |
| | |
| | | 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, |
| | |
| | | private Long id; |
| | | private String merchantName; |
| | | private String merchantType; |
| | | private String merchantRightsConfig; |
| | | private Integer status; |
| | | private Integer deleted; |
| | | } |
| | |
| | | """) |
| | | 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', |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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"; |
| | | |
| | |
| | | |
| | | private final BenefitTalentCardMapper cardMapper; |
| | | private final BenefitFamilyCardApplicationMapper familyMapper; |
| | | private final BenefitMerchantMapper merchantMapper; |
| | | private final BenefitMovieTicketRuleMapper movieRuleMapper; |
| | | private final BenefitCinemaCouponClaimMapper claimMapper; |
| | | private final BenefitFreeStayVoucherMapper voucherMapper; |
| | |
| | | public BenefitQuotaService( |
| | | BenefitTalentCardMapper cardMapper, |
| | | BenefitFamilyCardApplicationMapper familyMapper, |
| | | BenefitMerchantMapper merchantMapper, |
| | | BenefitMovieTicketRuleMapper movieRuleMapper, |
| | | BenefitCinemaCouponClaimMapper claimMapper, |
| | | BenefitFreeStayVoucherMapper voucherMapper, |
| | |
| | | ) { |
| | | this.cardMapper = cardMapper; |
| | | this.familyMapper = familyMapper; |
| | | this.merchantMapper = merchantMapper; |
| | | this.movieRuleMapper = movieRuleMapper; |
| | | this.claimMapper = claimMapper; |
| | | this.voucherMapper = voucherMapper; |
| | |
| | | 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(), |
| | |
| | | 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"); |
| | |
| | | |
| | | 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) |
| | |
| | | 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) { |
| | |
| | | .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(); |
| | |
| | | } |
| | | |
| | | 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( |
| | |
| | | List<Long> sharedCardIds |
| | | ) { |
| | | } |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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, |
| | |
| | | ) { |
| | | 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 |
| | |
| | | |
| | | 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); |
| | |
| | | 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 { |
| | |
| | | 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); |
| | | } |
| | |
| | | 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, "酒店权益券当前不可用"); |
| | | } |
| | |
| | | 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); |
| | |
| | | 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") |
| | |
| | | .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) |
| | |
| | | } |
| | | |
| | | 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")); |
| | | } |
| | |
| | | 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()); |
| | |
| | | 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()); |
| | |
| | | } |
| | | |
| | | 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) { |
| | |
| | | |
| | | private record MovieCouponChoice(BenefitCinemaCoupon coupon, int dailyLimit, int dailyUsed) { |
| | | } |
| | | } |
| | | } |
| | |
| | | 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); |
| | |
| | | .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, "电影票核销码已使用或已过期"); |
| | | } |
| | |
| | | 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) |
| | |
| | | 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); |
| | | } |
| | |
| | | |
| | | private record FreeStayCodeContext(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher) { |
| | | } |
| | | } |
| | | } |
| | |
| | | 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; |
| New file |
| | |
| | | -- 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; |