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