diff --git a/apps/h5-user/src/pages/RedeemPage.tsx b/apps/h5-user/src/pages/RedeemPage.tsx index 52724f9..3914be7 100644 --- a/apps/h5-user/src/pages/RedeemPage.tsx +++ b/apps/h5-user/src/pages/RedeemPage.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { request } from '../lib/api'; const REDEEM_TOKEN_TTL_SECONDS = 300; +const POLL_INTERVAL_MS = 2500; type BenefitSummary = { totalBalance: number; @@ -10,6 +11,21 @@ type BenefitSummary = { activeCouponCount: number; }; +type RedeemTokenStatus = + | { status: 'PENDING'; expireInSeconds: number; amount: number } + | { + status: 'CONSUMED'; + record: { + id: string; + redeemNo: string; + amount: number; + storeId: string; + storeName: string; + createdAt: string; + }; + } + | { status: 'EXPIRED' }; + function formatMoney(amount: number) { return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } @@ -37,10 +53,9 @@ export default function RedeemPage() { const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const timerRef = useRef(null); const presetApplied = useRef(false); + const successHandled = useRef(false); - const redeemableMax = couponId - ? (couponBalance ?? 0) - : (summary?.totalBalance ?? 0); + const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0); useEffect(() => { request('USER_H5', '/benefit/summary').then(setSummary).catch(() => {}); @@ -77,12 +92,41 @@ export default function RedeemPage() { } } - function closeModal() { + const closeModal = useCallback(() => { stopTimer(); setModalOpen(false); setToken(''); setTimerSec(REDEEM_TOKEN_TTL_SECONDS); - } + successHandled.current = false; + }, []); + + const handleRedeemExpired = useCallback(() => { + if (successHandled.current) return; + successHandled.current = true; + closeModal(); + sessionStorage.removeItem('redeemToken'); + sessionStorage.removeItem('redeemAmount'); + setMsg('核销码已失效,请重新生成'); + }, [closeModal]); + + const handleRedeemSuccess = useCallback( + (record: NonNullable['record']>) => { + if (successHandled.current) return; + successHandled.current = true; + stopTimer(); + setModalOpen(false); + setToken(''); + sessionStorage.setItem('lastRedeemRecordId', record.id); + sessionStorage.setItem('lastRedeemResult', JSON.stringify(record)); + sessionStorage.removeItem('redeemToken'); + sessionStorage.removeItem('redeemAmount'); + navigate('/redeem/success', { + replace: true, + state: { record }, + }); + }, + [navigate], + ); function startTimer() { stopTimer(); @@ -91,10 +135,7 @@ export default function RedeemPage() { setTimerSec((prev) => { if (prev <= 1) { stopTimer(); - window.setTimeout(() => { - window.alert('核销码已失效,请重新生成'); - closeModal(); - }, 0); + window.setTimeout(() => handleRedeemExpired(), 0); return 0; } return prev - 1; @@ -102,6 +143,35 @@ export default function RedeemPage() { }, 1000); } + useEffect(() => { + if (!modalOpen || !token) return undefined; + + let cancelled = false; + + async function pollStatus() { + try { + const status = await request('USER_H5', `/redeem/tokens/${token}/status`); + if (cancelled || successHandled.current) return; + if (status.status === 'CONSUMED' && status.record) { + handleRedeemSuccess(status.record); + } else if (status.status === 'EXPIRED') { + handleRedeemExpired(); + } else if (status.status === 'PENDING' && status.expireInSeconds > 0) { + setTimerSec((prev) => Math.min(prev, status.expireInSeconds)); + } + } catch { + /* 轮询失败忽略,下次重试 */ + } + } + + void pollStatus(); + const pollId = window.setInterval(() => void pollStatus(), POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(pollId); + }; + }, [modalOpen, token, handleRedeemSuccess, handleRedeemExpired]); + function parseAmount() { const value = Number(amountInput); return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0; @@ -129,6 +199,7 @@ export default function RedeemPage() { setLoading(true); setMsg(''); + successHandled.current = false; try { const body: { amount: number; couponId?: string } = { amount }; if (couponId) body.couponId = couponId; diff --git a/apps/h5-user/src/pages/RedeemSuccessPage.tsx b/apps/h5-user/src/pages/RedeemSuccessPage.tsx index 66f7a21..73459c3 100644 --- a/apps/h5-user/src/pages/RedeemSuccessPage.tsx +++ b/apps/h5-user/src/pages/RedeemSuccessPage.tsx @@ -1,53 +1,128 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; import { request } from '../lib/api'; +type RedeemRecord = { + id: string; + redeemNo: string; + amount: number; + storeId: string; + storeName: string; + createdAt: string; +}; + +function formatMoney(amount: number) { + return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function StarRating({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (score: number) => void; +}) { + return ( +
+ {label} +
+ {[1, 2, 3, 4, 5].map((score) => ( + + ))} +
+
+ ); +} + export default function RedeemSuccessPage() { const navigate = useNavigate(); + const location = useLocation(); const [serviceScore, setServiceScore] = useState(5); const [envScore, setEnvScore] = useState(5); const [loading, setLoading] = useState(false); + const record = useMemo(() => { + const fromState = (location.state as { record?: RedeemRecord })?.record; + if (fromState) return fromState; + try { + const cached = sessionStorage.getItem('lastRedeemResult'); + return cached ? (JSON.parse(cached) as RedeemRecord) : null; + } catch { + return null; + } + }, [location.state]); + + const amount = Number(record?.amount ?? 0); + const storeName = record?.storeName || '门店'; + const redeemNo = record?.redeemNo || '—'; + const redeemedAt = record?.createdAt + ? new Date(record.createdAt).toLocaleString('zh-CN') + : new Date().toLocaleString('zh-CN'); + async function submit() { setLoading(true); try { - const redeemRecordId = sessionStorage.getItem('lastRedeemRecordId'); + const redeemRecordId = record?.id || sessionStorage.getItem('lastRedeemRecordId'); if (redeemRecordId) { await request('USER_H5', '/redeem/ratings', { method: 'POST', body: JSON.stringify({ redeemRecordId, serviceScore, - environmentScore: envScore, + envScore, }), }); } } catch { - /* preV1: allow navigate even if rating fails */ + /* 评价失败不阻塞返回 */ } finally { setLoading(false); - navigate('/benefit'); + sessionStorage.removeItem('lastRedeemRecordId'); + sessionStorage.removeItem('lastRedeemResult'); + navigate('/benefit', { replace: true }); } } return (
- navigate('/benefit')} /> -
+ navigate('/benefit', { replace: true })} /> +

核销成功

-

请为门店服务评分

+

¥ {formatMoney(amount)}

+

已在 {storeName} 完成核销

-
-
- - setServiceScore(Number(e.target.value))} /> + +
+
+ 核销门店 + {storeName}
-
- - setEnvScore(Number(e.target.value))} /> +
+ 核销时间 + {redeemedAt}
+
+ 核销单号 + {redeemNo} +
+
+ +
+

为门店服务评分

+ + diff --git a/apps/h5-user/src/styles.css b/apps/h5-user/src/styles.css index 9fee4b3..05eec1c 100644 --- a/apps/h5-user/src/styles.css +++ b/apps/h5-user/src/styles.css @@ -1223,6 +1223,95 @@ font-weight: 700; } +.redeem-success-page { + padding-bottom: 24px; +} + +.redeem-success-hero { + text-align: center; + padding: 24px 16px 8px; +} + +.redeem-success-amount { + margin-top: 12px; + font-size: 28px; + font-weight: 700; + color: var(--color-primary, #a02d30); +} + +.redeem-success-details { + margin: 16px; +} + +.redeem-success-detail-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + font-size: 14px; +} + +.redeem-success-detail-row:last-child { + border-bottom: none; +} + +.redeem-success-detail-row span:first-child { + color: var(--color-on-surface-variant, #666); + flex-shrink: 0; +} + +.redeem-success-detail-row span:last-child { + text-align: right; + word-break: break-all; +} + +.redeem-success-mono { + font-family: ui-monospace, monospace; + font-size: 13px; +} + +.redeem-success-rating { + margin: 0 16px; +} + +.redeem-success-rating-title { + margin: 0 0 16px; + font-weight: 600; + text-align: center; +} + +.redeem-rating-row { + margin-bottom: 20px; +} + +.redeem-rating-label { + display: block; + margin-bottom: 8px; + font-size: 14px; + color: var(--color-on-surface-variant, #666); +} + +.redeem-star-row { + display: flex; + justify-content: center; + gap: 8px; +} + +.redeem-star-btn { + border: none; + background: none; + font-size: 32px; + line-height: 1; + color: #ddd; + cursor: pointer; + padding: 0; +} + +.redeem-star-btn.active { + color: #ffc107; +} + /* ── 登录页(stitch user_用户登录页_C端小程序) ── */ .login-page { min-height: 100vh; diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 3710db0..3e31d4b 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -81,3 +81,5 @@ export const CLIENT_APP_ACTOR_MAP: Record = { export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE'; export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE'; export const REDEEM_TOKEN_TTL_SECONDS = 300; +/** 核销成功后供用户端轮询结果,略长于 token TTL */ +export const REDEEM_RESULT_TTL_SECONDS = 600; diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts index 718d229..d841289 100644 --- a/server/dukang-api/src/modules/redeem/redeem.controller.ts +++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts @@ -18,6 +18,11 @@ export class UserRedeemController { return this.redeemService.getToken(token); } + @Get('tokens/:token/status') + getTokenStatus(@CurrentUser() user: AuthUser, @Param('token') token: string) { + return this.redeemService.getTokenStatus(token, user.actorId); + } + @Post('ratings') rating(@CurrentUser() user: AuthUser, @Body() body: Record) { return this.redeemService.submitRating(user.actorId, body as never); diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index e10f402..780c547 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -10,7 +10,7 @@ import { validateRedeemAmount, allocateBenefitCoupons, } from '@dukang/domain'; -import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types'; +import { REDEEM_RESULT_TTL_SECONDS, REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -26,6 +26,16 @@ type TokenPayload = { allocations?: Array<{ couponId: string; amount: number }>; }; +type RedeemResultPayload = { + recordId: string; + redeemNo: string; + userId: string; + amount: number; + storeId: string; + storeName: string; + createdAt: string; +}; + @Injectable() export class RedeemService { constructor( @@ -93,6 +103,41 @@ export class RedeemService { return cached; } + async getTokenStatus(token: string, userId: bigint) { + const result = await this.redis.getJson(`redeem:result:${token}`); + if (result) { + if (result.userId !== userId.toString()) { + throw new NotFoundException('核销码不存在'); + } + return { + status: 'CONSUMED' as const, + record: { + id: result.recordId, + redeemNo: result.redeemNo, + amount: result.amount, + storeId: result.storeId, + storeName: result.storeName, + createdAt: result.createdAt, + }, + }; + } + + const cached = await this.redis.getJson(`redeem:token:${token}`); + if (cached) { + if (cached.userId !== userId.toString()) { + throw new NotFoundException('核销码不存在'); + } + const ttl = await this.redis.ttl(`redeem:token:${token}`); + return { + status: 'PENDING' as const, + expireInSeconds: ttl > 0 ? ttl : 0, + amount: cached.amount, + }; + } + + return { status: 'EXPIRED' as const }; + } + async previewRedeem(storeAccountId: bigint, token: string) { const account = await this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId }, @@ -223,17 +268,37 @@ export class RedeemService { throw e; } + await this.redis.setJson( + `redeem:result:${body.token}`, + { + recordId: record.id.toString(), + redeemNo: record.redeemNo, + userId: cached.userId, + amount, + storeId: account.storeId.toString(), + storeName: account.store.name, + createdAt: record.createdAt.toISOString(), + } satisfies RedeemResultPayload, + REDEEM_RESULT_TTL_SECONDS, + ); await this.redis.del(`redeem:token:${body.token}`); + const redeemExtra = { + redeemRecordId: record.id.toString(), + storeId: account.storeId.toString(), + amount, + }; this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', { eventName: 'benefit_redeem_success', refType: 'STORE', refId: account.storeId, - extraJson: { - redeemRecordId: record.id.toString(), - storeId: account.storeId.toString(), - amount, - }, + extraJson: redeemExtra, + }); + this.analyticsService.trackOneSafe(BigInt(cached.userId), 'USER_H5', { + eventName: 'benefit_redeem_success', + refType: 'STORE', + refId: account.storeId, + extraJson: redeemExtra, }); return serializeBigInt(record);