feat(redeem): streamline phone redemption confirmation
CI / verify (pull_request) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 11:41:06 +08:00
parent 36bec94639
commit eb96b36d0b
6 changed files with 181 additions and 211 deletions
+85 -210
View File
@@ -1,33 +1,22 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
type BalanceResult = {
sessionId: string;
totalBalance: number;
maskedPhone: string;
user?: { nickname?: string; phone?: string; userNo?: string };
};
function formatAmount(n: number) { function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
type Step = 'lookup' | 'amount' | 'confirm';
export default function PhoneRedeemPage() { export default function PhoneRedeemPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [step, setStep] = useState<Step>('lookup');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [lookupCode, setLookupCode] = useState('');
const [confirmCode, setConfirmCode] = useState('');
const [amount, setAmount] = 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 [storeName, setStoreName] = useState('');
const [storeClosed, setStoreClosed] = useState(false); const [storeClosed, setStoreClosed] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [lookupCooldown, setLookupCooldown] = useState(0);
const [confirmCooldown, setConfirmCooldown] = useState(0); const [confirmCooldown, setConfirmCooldown] = useState(0);
useEffect(() => { useEffect(() => {
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
.catch(() => setStoreName('当前门店')); .catch(() => setStoreName('当前门店'));
}, []); }, []);
useEffect(() => {
if (lookupCooldown <= 0) return;
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [lookupCooldown]);
useEffect(() => { useEffect(() => {
if (confirmCooldown <= 0) return; if (confirmCooldown <= 0) return;
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000); const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [confirmCooldown]); }, [confirmCooldown]);
async function sendLookupSms() { async function sendConfirmSms() {
if (!/^1\d{10}$/.test(phone.trim())) { if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号'); setMsg('请输入正确的手机号');
return; 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) { if (storeClosed) {
setMsg('门店未营业,无法核销'); setMsg('门店未营业,无法核销');
return; return;
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
setMsg('请输入有效核销金额'); setMsg('请输入有效核销金额');
return; return;
} }
if (balance && value > balance.totalBalance) {
setMsg('核销金额不能超过可用权益');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
await request('SHOP_H5', '/shop/redeem/phone/prepare', { const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
method: 'POST', method: 'POST',
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }), body: JSON.stringify({ phone: phone.trim(), amount: value }),
}); });
setPrepared(result);
setConfirmCode('');
setConfirmCooldown(60); setConfirmCooldown(60);
setStep('confirm'); setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '发起核销失败'); setPrepared(null);
setMsg(e instanceof Error ? e.message : '发送验证码失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
async function confirmRedeem() { async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
return;
}
if (!confirmCode.trim()) { if (!confirmCode.trim()) {
setMsg('请输入确认验证码'); setMsg('请输入确认验证码');
return; return;
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', { const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
sessionId: balance?.sessionId, sessionId: prepared.sessionId,
code: confirmCode.trim(), code: confirmCode.trim(),
}), }),
}); });
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result)); sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', { navigate('/redeem/success', {
state: { result, storeName, user: balance?.user }, state: { result, storeName, user: prepared.user },
}); });
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败'); 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 ( return (
<div className="shop-redeem-page"> <div className="shop-redeem-page">
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
</div> </div>
<div className="shop-redeem-body"> <div className="shop-redeem-body">
{step === 'lookup' && ( <div className="shop-phone-field">
<> <label className="shop-phone-label"></label>
<div className="shop-phone-field"> <input
<label className="shop-phone-label"></label> className="shop-phone-input"
<input type="tel"
className="shop-phone-input" maxLength={11}
type="tel" placeholder="请输入用户手机号"
maxLength={11} value={phone}
placeholder="请输入用户手机号" disabled={loading}
value={phone} onChange={(e) => {
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))} setPhone(e.target.value.replace(/\D/g, ''));
/> setPrepared(null);
</div> setConfirmCode('');
<div className="shop-phone-field"> setConfirmCooldown(0);
<label className="shop-phone-label"></label> }}
<div className="shop-phone-code-row"> />
<input </div>
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>
</>
)}
{step === 'amount' && balance && ( <div className="shop-phone-field">
<> <label className="shop-phone-label"></label>
<div className="shop-redeem-user"> <input
<div className="shop-redeem-user-left"> className="shop-phone-input"
<span className="material-symbols-outlined">person</span> type="number"
<span></span> min={0.01}
</div> step={0.01}
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}> placeholder="请输入待核销金额"
{userLabel} value={amount}
</span> disabled={loading}
</div> onChange={(e) => {
<div className="shop-redeem-amount-section"> setAmount(e.target.value);
<p className="shop-redeem-amount-label"></p> setPrepared(null);
<div className="shop-redeem-amount"> setConfirmCode('');
<span className="shop-redeem-amount-symbol">¥</span> setConfirmCooldown(0);
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span> }}
</div> />
</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>
</>
)}
{step === 'confirm' && balance && ( <div className="shop-phone-field">
<> <label className="shop-phone-label"></label>
<div className="shop-redeem-details"> <div className="shop-phone-code-row">
<div className="shop-redeem-detail-row"> <input
<span></span> className="shop-phone-input"
<span>{userLabel}</span> type="text"
</div> inputMode="numeric"
<div className="shop-redeem-detail-row"> maxLength={6}
<span></span> placeholder="输入用户收到的验证码"
<span>¥{formatAmount(Number(amount))}</span> value={confirmCode}
</div> onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
</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>
<button <button
type="button" type="button"
className="shop-redeem-confirm-btn" className="shop-phone-code-btn"
disabled={loading || storeClosed} disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
onClick={() => void confirmRedeem()} onClick={() => void sendConfirmSms()}
> >
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`} {confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
</button> </button>
<button </div>
type="button" <p className="shop-redeem-hint" style={{ marginTop: 8 }}>
className="shop-phone-link-btn"
onClick={() => { </p>
setStep('amount'); </div>
setConfirmCode('');
}} <button
> type="button"
className="shop-redeem-confirm-btn"
</button> disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
</> onClick={() => void confirmRedeem()}
)} >
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
</button>
{msg && <p className="shop-redeem-error">{msg}</p>} {msg && <p className="shop-redeem-error">{msg}</p>}
</div> </div>
+16
View File
@@ -45,6 +45,22 @@ export interface RedeemPhonePrepareDto {
expireInSeconds: number; 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 type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = { export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import type { RedeemPhoneDirectPrepareRequest } from '@dukang/shared-types';
export class RedeemPhoneSendLookupSmsDto { export class RedeemPhoneSendLookupSmsDto {
@IsString() @IsString()
@@ -28,6 +29,17 @@ export class RedeemPhonePrepareDto {
amount: number; amount: number;
} }
export class RedeemPhoneDirectPrepareDto implements RedeemPhoneDirectPrepareRequest {
@IsString()
@IsNotEmpty()
phone: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount: number;
}
export class RedeemPhoneConfirmDto { export class RedeemPhoneConfirmDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -6,6 +6,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { import {
RedeemPhoneBalanceDto, RedeemPhoneBalanceDto,
RedeemPhoneConfirmDto, RedeemPhoneConfirmDto,
RedeemPhoneDirectPrepareDto,
RedeemPhonePrepareDto, RedeemPhonePrepareDto,
RedeemPhoneSendLookupSmsDto, RedeemPhoneSendLookupSmsDto,
} from './dto/phone-redeem.dto'; } 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') @Post('phone/confirm')
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) { phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
return this.redeemService.confirmPhoneRedeem( return this.redeemService.confirmPhoneRedeem(
@@ -311,6 +311,62 @@ export class RedeemService {
return session; 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) { async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId); const session = await this.loadPhoneSession(sessionId, storeAccountId);
+1 -1
View File
@@ -254,7 +254,7 @@
| 模块 | 要点 | | 模块 | 要点 |
|------|------| |------|------|
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 | | 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
| 核销 | 扫码大按钮 + 手机号通道;今日汇总;弱网处理 | | 核销 | 扫码大按钮 + 手机号通道;手机号、金额、验证码与确认核销同页完成,先按手机号和金额发送验证码,验证成功后直接核销;今日汇总;弱网处理 |
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 | | 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 | | 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3 | | 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3 |