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 { 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>
|
||||||
|
|||||||
@@ -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
@@ -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) |
|
||||||
|
|||||||
Reference in New Issue
Block a user