核销成功提示
This commit is contained in:
@@ -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 { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
const REDEEM_TOKEN_TTL_SECONDS = 300;
|
const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||||
|
const POLL_INTERVAL_MS = 2500;
|
||||||
|
|
||||||
type BenefitSummary = {
|
type BenefitSummary = {
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
@@ -10,6 +11,21 @@ type BenefitSummary = {
|
|||||||
activeCouponCount: number;
|
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) {
|
function formatMoney(amount: number) {
|
||||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
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 [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const presetApplied = useRef(false);
|
const presetApplied = useRef(false);
|
||||||
|
const successHandled = useRef(false);
|
||||||
|
|
||||||
const redeemableMax = couponId
|
const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0);
|
||||||
? (couponBalance ?? 0)
|
|
||||||
: (summary?.totalBalance ?? 0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
||||||
@@ -77,12 +92,41 @@ export default function RedeemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
const closeModal = useCallback(() => {
|
||||||
stopTimer();
|
stopTimer();
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setToken('');
|
setToken('');
|
||||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
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<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['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() {
|
function startTimer() {
|
||||||
stopTimer();
|
stopTimer();
|
||||||
@@ -91,10 +135,7 @@ export default function RedeemPage() {
|
|||||||
setTimerSec((prev) => {
|
setTimerSec((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
stopTimer();
|
stopTimer();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => handleRedeemExpired(), 0);
|
||||||
window.alert('核销码已失效,请重新生成');
|
|
||||||
closeModal();
|
|
||||||
}, 0);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return prev - 1;
|
return prev - 1;
|
||||||
@@ -102,6 +143,35 @@ export default function RedeemPage() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen || !token) return undefined;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const status = await request<RedeemTokenStatus>('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() {
|
function parseAmount() {
|
||||||
const value = Number(amountInput);
|
const value = Number(amountInput);
|
||||||
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
|
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
|
||||||
@@ -129,6 +199,7 @@ export default function RedeemPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
|
successHandled.current = false;
|
||||||
try {
|
try {
|
||||||
const body: { amount: number; couponId?: string } = { amount };
|
const body: { amount: number; couponId?: string } = { amount };
|
||||||
if (couponId) body.couponId = couponId;
|
if (couponId) body.couponId = couponId;
|
||||||
|
|||||||
@@ -1,53 +1,128 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { request } from '../lib/api';
|
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 (
|
||||||
|
<div className="redeem-rating-row">
|
||||||
|
<span className="redeem-rating-label">{label}</span>
|
||||||
|
<div className="redeem-star-row" role="group" aria-label={label}>
|
||||||
|
{[1, 2, 3, 4, 5].map((score) => (
|
||||||
|
<button
|
||||||
|
key={score}
|
||||||
|
type="button"
|
||||||
|
className={`redeem-star-btn${score <= value ? ' active' : ''}`}
|
||||||
|
aria-label={`${score} 星`}
|
||||||
|
onClick={() => onChange(score)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RedeemSuccessPage() {
|
export default function RedeemSuccessPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [serviceScore, setServiceScore] = useState(5);
|
const [serviceScore, setServiceScore] = useState(5);
|
||||||
const [envScore, setEnvScore] = useState(5);
|
const [envScore, setEnvScore] = useState(5);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const record = useMemo<RedeemRecord | null>(() => {
|
||||||
|
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() {
|
async function submit() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const redeemRecordId = sessionStorage.getItem('lastRedeemRecordId');
|
const redeemRecordId = record?.id || sessionStorage.getItem('lastRedeemRecordId');
|
||||||
if (redeemRecordId) {
|
if (redeemRecordId) {
|
||||||
await request('USER_H5', '/redeem/ratings', {
|
await request('USER_H5', '/redeem/ratings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
redeemRecordId,
|
redeemRecordId,
|
||||||
serviceScore,
|
serviceScore,
|
||||||
environmentScore: envScore,
|
envScore,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* preV1: allow navigate even if rating fails */
|
/* 评价失败不阻塞返回 */
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
navigate('/benefit');
|
sessionStorage.removeItem('lastRedeemRecordId');
|
||||||
|
sessionStorage.removeItem('lastRedeemResult');
|
||||||
|
navigate('/benefit', { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab redeem-success-page">
|
<div className="page-no-tab redeem-success-page">
|
||||||
<PageHeader title="核销成功" onBack={() => navigate('/benefit')} />
|
<PageHeader title="核销成功" onBack={() => navigate('/benefit', { replace: true })} />
|
||||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
<div className="redeem-success-hero">
|
||||||
<div className="success-icon">✓</div>
|
<div className="success-icon">✓</div>
|
||||||
<h2 className="headline-lg text-primary">核销成功</h2>
|
<h2 className="headline-lg text-primary">核销成功</h2>
|
||||||
<p className="text-variant body-md" style={{ marginTop: 8 }}>请为门店服务评分</p>
|
<p className="redeem-success-amount">¥ {formatMoney(amount)}</p>
|
||||||
|
<p className="text-variant body-md">已在 {storeName} 完成核销</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
|
||||||
<div className="form-group">
|
<div className="card redeem-success-details">
|
||||||
<label>服务评分 (1-5)</label>
|
<div className="redeem-success-detail-row">
|
||||||
<input type="number" min={1} max={5} value={serviceScore} onChange={(e) => setServiceScore(Number(e.target.value))} />
|
<span>核销门店</span>
|
||||||
|
<span>{storeName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="redeem-success-detail-row">
|
||||||
<label>环境评分 (1-5)</label>
|
<span>核销时间</span>
|
||||||
<input type="number" min={1} max={5} value={envScore} onChange={(e) => setEnvScore(Number(e.target.value))} />
|
<span>{redeemedAt}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="redeem-success-detail-row">
|
||||||
|
<span>核销单号</span>
|
||||||
|
<span className="redeem-success-mono">{redeemNo}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card redeem-success-rating">
|
||||||
|
<p className="redeem-success-rating-title">为门店服务评分</p>
|
||||||
|
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
|
||||||
|
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
|
||||||
<button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}>
|
<button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}>
|
||||||
{loading ? '提交中...' : '完成'}
|
{loading ? '提交中...' : '完成'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1223,6 +1223,95 @@
|
|||||||
font-weight: 700;
|
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端小程序) ── */
|
/* ── 登录页(stitch user_用户登录页_C端小程序) ── */
|
||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
@@ -81,3 +81,5 @@ export const CLIENT_APP_ACTOR_MAP: Record<ClientApp, ActorType> = {
|
|||||||
export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE';
|
export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE';
|
||||||
export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE';
|
export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE';
|
||||||
export const REDEEM_TOKEN_TTL_SECONDS = 300;
|
export const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||||
|
/** 核销成功后供用户端轮询结果,略长于 token TTL */
|
||||||
|
export const REDEEM_RESULT_TTL_SECONDS = 600;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export class UserRedeemController {
|
|||||||
return this.redeemService.getToken(token);
|
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')
|
@Post('ratings')
|
||||||
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||||
return this.redeemService.submitRating(user.actorId, body as never);
|
return this.redeemService.submitRating(user.actorId, body as never);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
allocateBenefitCoupons,
|
allocateBenefitCoupons,
|
||||||
} from '@dukang/domain';
|
} 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 { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
@@ -26,6 +26,16 @@ type TokenPayload = {
|
|||||||
allocations?: Array<{ couponId: string; amount: number }>;
|
allocations?: Array<{ couponId: string; amount: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RedeemResultPayload = {
|
||||||
|
recordId: string;
|
||||||
|
redeemNo: string;
|
||||||
|
userId: string;
|
||||||
|
amount: number;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedeemService {
|
export class RedeemService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -93,6 +103,41 @@ export class RedeemService {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTokenStatus(token: string, userId: bigint) {
|
||||||
|
const result = await this.redis.getJson<RedeemResultPayload>(`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<TokenPayload>(`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) {
|
async previewRedeem(storeAccountId: bigint, token: string) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
@@ -223,17 +268,37 @@ export class RedeemService {
|
|||||||
throw e;
|
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}`);
|
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', {
|
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
|
||||||
eventName: 'benefit_redeem_success',
|
eventName: 'benefit_redeem_success',
|
||||||
refType: 'STORE',
|
refType: 'STORE',
|
||||||
refId: account.storeId,
|
refId: account.storeId,
|
||||||
extraJson: {
|
extraJson: redeemExtra,
|
||||||
redeemRecordId: record.id.toString(),
|
});
|
||||||
storeId: account.storeId.toString(),
|
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'USER_H5', {
|
||||||
amount,
|
eventName: 'benefit_redeem_success',
|
||||||
},
|
refType: 'STORE',
|
||||||
|
refId: account.storeId,
|
||||||
|
extraJson: redeemExtra,
|
||||||
});
|
});
|
||||||
|
|
||||||
return serializeBigInt(record);
|
return serializeBigInt(record);
|
||||||
|
|||||||
Reference in New Issue
Block a user