diff --git a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx index d38a91b..c054ebf 100644 --- a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx +++ b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx @@ -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('lookup'); const [phone, setPhone] = useState(''); - const [lookupCode, setLookupCode] = useState(''); - const [confirmCode, setConfirmCode] = useState(''); const [amount, setAmount] = useState(''); - const [balance, setBalance] = useState(null); + const [confirmCode, setConfirmCode] = useState(''); + const [prepared, setPrepared] = useState(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('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('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>('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 (
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
- {step === 'lookup' && ( - <> -
- - setPhone(e.target.value.replace(/\D/g, ''))} - /> -
-
- -
- setLookupCode(e.target.value.replace(/\D/g, ''))} - /> - -
-
- - - )} +
+ + { + setPhone(e.target.value.replace(/\D/g, '')); + setPrepared(null); + setConfirmCode(''); + setConfirmCooldown(0); + }} + /> +
- {step === 'amount' && balance && ( - <> -
-
- person - 用户 -
- - {userLabel} - -
-
-

可用好客权益

-
- ¥ - {formatAmount(balance.totalBalance)} -
-
-
- - setAmount(e.target.value)} - /> -
- - - - )} +
+ + { + setAmount(e.target.value); + setPrepared(null); + setConfirmCode(''); + setConfirmCooldown(0); + }} + /> +
- {step === 'confirm' && balance && ( - <> -
-
- 用户 - {userLabel} -
-
- 核销金额 - ¥{formatAmount(Number(amount))} -
-
-
- - setConfirmCode(e.target.value.replace(/\D/g, ''))} - /> -

- {confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'} -

-
+
+ +
+ setConfirmCode(e.target.value.replace(/\D/g, ''))} + /> - - - )} +
+

+ 验证码将发送到用户手机号,验证成功后直接完成核销。 +

+
+ + {msg &&

{msg}

}
diff --git a/packages/shared-types/src/redeem.ts b/packages/shared-types/src/redeem.ts index f0eee3f..ad622c3 100644 --- a/packages/shared-types/src/redeem.ts +++ b/packages/shared-types/src/redeem.ts @@ -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 = { diff --git a/server/dukang-api/src/modules/redeem/dto/phone-redeem.dto.ts b/server/dukang-api/src/modules/redeem/dto/phone-redeem.dto.ts index 846a059..e645570 100644 --- a/server/dukang-api/src/modules/redeem/dto/phone-redeem.dto.ts +++ b/server/dukang-api/src/modules/redeem/dto/phone-redeem.dto.ts @@ -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() diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts index 0711859..4db8803 100644 --- a/server/dukang-api/src/modules/redeem/redeem.controller.ts +++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts @@ -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( diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index b6ba864..dcdb07b 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -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); diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index e2a75b8..b613bd9 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -254,7 +254,7 @@ | 模块 | 要点 | |------|------| | 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 | -| 核销 | 扫码大按钮 + 手机号通道;今日汇总;弱网处理 | +| 核销 | 扫码大按钮 + 手机号通道;手机号、金额、验证码与确认核销同页完成,先按手机号和金额发送验证码,验证成功后直接核销;今日汇总;弱网处理 | | 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 | | 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 | | 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |