feat(redeem): streamline phone redemption confirmation
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,33 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type BalanceResult = {
|
||||
sessionId: string;
|
||||
totalBalance: number;
|
||||
maskedPhone: string;
|
||||
user?: { nickname?: string; phone?: string; userNo?: string };
|
||||
};
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Step = 'lookup' | 'amount' | 'confirm';
|
||||
|
||||
export default function PhoneRedeemPage() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<Step>('lookup');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [lookupCode, setLookupCode] = useState('');
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [balance, setBalance] = useState<BalanceResult | null>(null);
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lookupCooldown, setLookupCooldown] = useState(0);
|
||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (lookupCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [lookupCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [confirmCooldown]);
|
||||
|
||||
async function sendLookupSms() {
|
||||
async function sendConfirmSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim() }),
|
||||
});
|
||||
setLookupCooldown(60);
|
||||
setMsg('验证码已发送至用户手机');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryBalance() {
|
||||
if (!lookupCode.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
|
||||
});
|
||||
setBalance(res);
|
||||
setStep('amount');
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '查询失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRedeem() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
|
||||
setMsg('请输入有效核销金额');
|
||||
return;
|
||||
}
|
||||
if (balance && value > balance.totalBalance) {
|
||||
setMsg('核销金额不能超过可用权益');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
|
||||
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
|
||||
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
||||
});
|
||||
setPrepared(result);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(60);
|
||||
setStep('confirm');
|
||||
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
|
||||
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发起核销失败');
|
||||
setPrepared(null);
|
||||
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!prepared) {
|
||||
setMsg('请先发送核销验证码');
|
||||
return;
|
||||
}
|
||||
if (!confirmCode.trim()) {
|
||||
setMsg('请输入确认验证码');
|
||||
return;
|
||||
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: balance?.sessionId,
|
||||
sessionId: prepared.sessionId,
|
||||
code: confirmCode.trim(),
|
||||
}),
|
||||
});
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||
navigate('/redeem/success', {
|
||||
state: { result, storeName, user: balance?.user },
|
||||
state: { result, storeName, user: prepared.user },
|
||||
});
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
|
||||
const amountValue = Number(amount);
|
||||
const canSendCode =
|
||||
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-body">
|
||||
{step === 'lookup' && (
|
||||
<>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="用户收到的验证码"
|
||||
value={lookupCode}
|
||||
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || lookupCooldown > 0 || !phone.trim()}
|
||||
onClick={() => void sendLookupSms()}
|
||||
>
|
||||
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed}
|
||||
onClick={() => void queryBalance()}
|
||||
>
|
||||
查询权益
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value.replace(/\D/g, ''));
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === 'amount' && balance && (
|
||||
<>
|
||||
<div className="shop-redeem-user">
|
||||
<div className="shop-redeem-user-left">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<span>用户</span>
|
||||
</div>
|
||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
||||
{userLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-redeem-amount-section">
|
||||
<p className="shop-redeem-amount-label">可用好客权益</p>
|
||||
<div className="shop-redeem-amount">
|
||||
<span className="shop-redeem-amount-symbol">¥</span>
|
||||
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销金额</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="请输入核销金额"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed || balance.totalBalance <= 0}
|
||||
onClick={() => void prepareRedeem()}
|
||||
>
|
||||
发送确认验证码并核销
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-link-btn"
|
||||
onClick={() => {
|
||||
setStep('lookup');
|
||||
setBalance(null);
|
||||
setAmount('');
|
||||
setLookupCode('');
|
||||
}}
|
||||
>
|
||||
更换手机号
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">待核销金额</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="请输入待核销金额"
|
||||
value={amount}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value);
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === 'confirm' && balance && (
|
||||
<>
|
||||
<div className="shop-redeem-details">
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>用户</span>
|
||||
<span>{userLabel}</span>
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>核销金额</span>
|
||||
<span>¥{formatAmount(Number(amount))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销确认验证码</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="用户手机收到的确认码"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="输入用户收到的验证码"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed}
|
||||
onClick={() => void confirmRedeem()}
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||
onClick={() => void sendConfirmSms()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-link-btn"
|
||||
onClick={() => {
|
||||
setStep('amount');
|
||||
setConfirmCode('');
|
||||
}}
|
||||
>
|
||||
返回修改金额
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||
验证码将发送到用户手机号,验证成功后直接完成核销。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
|
||||
onClick={() => void confirmRedeem()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||
</button>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,22 @@ export interface RedeemPhonePrepareDto {
|
||||
expireInSeconds: number;
|
||||
}
|
||||
|
||||
export interface RedeemPhoneDirectPrepareRequest {
|
||||
phone: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface RedeemPhoneDirectPrepareResult extends RedeemPhonePrepareDto {
|
||||
totalBalance: number;
|
||||
maskedPhone: string;
|
||||
user: {
|
||||
id: string;
|
||||
userNo?: string | null;
|
||||
nickname?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||
|
||||
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||
import type { RedeemPhoneDirectPrepareRequest } from '@dukang/shared-types';
|
||||
|
||||
export class RedeemPhoneSendLookupSmsDto {
|
||||
@IsString()
|
||||
@@ -28,6 +29,17 @@ export class RedeemPhonePrepareDto {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export class RedeemPhoneDirectPrepareDto implements RedeemPhoneDirectPrepareRequest {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export class RedeemPhoneConfirmDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import {
|
||||
RedeemPhoneBalanceDto,
|
||||
RedeemPhoneConfirmDto,
|
||||
RedeemPhoneDirectPrepareDto,
|
||||
RedeemPhonePrepareDto,
|
||||
RedeemPhoneSendLookupSmsDto,
|
||||
} from './dto/phone-redeem.dto';
|
||||
@@ -107,6 +108,16 @@ export class ShopRedeemController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('phone/prepare-direct')
|
||||
phonePrepareDirect(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneDirectPrepareDto) {
|
||||
return this.redeemService.preparePhoneRedeemDirect(
|
||||
user.actorId,
|
||||
user.storeId!,
|
||||
body.phone,
|
||||
body.amount,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('phone/confirm')
|
||||
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
|
||||
return this.redeemService.confirmPhoneRedeem(
|
||||
|
||||
@@ -311,6 +311,62 @@ export class RedeemService {
|
||||
return session;
|
||||
}
|
||||
|
||||
async preparePhoneRedeemDirect(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
phone: string,
|
||||
amount: number,
|
||||
) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||||
const user = await this.resolveUserByPhone(normalizedPhone);
|
||||
const { allocations, totalBalance } = await this.computeDirectAllocations(user.id, amount);
|
||||
const sessionId = randomBytes(16).toString('hex');
|
||||
|
||||
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_CONFIRM, {
|
||||
clientApp: ClientApp.SHOP_H5,
|
||||
});
|
||||
|
||||
await this.redis.setJson(
|
||||
this.phoneSessionKey(sessionId),
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
phone: normalizedPhone,
|
||||
storeAccountId: storeAccountId.toString(),
|
||||
storeId: account.storeId.toString(),
|
||||
amount,
|
||||
allocations,
|
||||
confirmPrepared: true,
|
||||
} satisfies PhoneRedeemSession,
|
||||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||||
);
|
||||
|
||||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||
storeId: account.storeId,
|
||||
eventName: 'store_redeem_phone_prepare',
|
||||
extraJson: {
|
||||
sessionId,
|
||||
amount,
|
||||
phone: this.maskPhoneForStore(normalizedPhone),
|
||||
flow: 'direct',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt({
|
||||
sessionId,
|
||||
amount,
|
||||
totalBalance,
|
||||
maskedPhone: this.maskPhoneForStore(normalizedPhone),
|
||||
expireInSeconds: REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||||
user: {
|
||||
id: user.id,
|
||||
userNo: user.userNo,
|
||||
nickname: user.nickname,
|
||||
phone: this.maskPhoneForStore(normalizedPhone),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||||
|
||||
+1
-1
@@ -254,7 +254,7 @@
|
||||
| 模块 | 要点 |
|
||||
|------|------|
|
||||
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
|
||||
| 核销 | 扫码大按钮 + 手机号通道;今日汇总;弱网处理 |
|
||||
| 核销 | 扫码大按钮 + 手机号通道;手机号、金额、验证码与确认核销同页完成,先按手机号和金额发送验证码,验证成功后直接核销;今日汇总;弱网处理 |
|
||||
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
||||
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
||||
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
||||
|
||||
Reference in New Issue
Block a user