diff --git a/apps/admin-web/src/pages/RedeemDebugPage.tsx b/apps/admin-web/src/pages/RedeemDebugPage.tsx index bdad447..d48aaa1 100644 --- a/apps/admin-web/src/pages/RedeemDebugPage.tsx +++ b/apps/admin-web/src/pages/RedeemDebugPage.tsx @@ -22,16 +22,25 @@ export default function RedeemDebugPage() { const [createForm] = Form.useForm(); const [previewForm] = Form.useForm(); const [confirmForm] = Form.useForm(); + const [phoneLookupForm] = Form.useForm(); + const [phoneBalanceForm] = Form.useForm(); + const [phonePrepareForm] = Form.useForm(); + const [phoneConfirmForm] = Form.useForm(); const [loading, setLoading] = useState(false); const [createResult, setCreateResult] = useState(null); const [previewResult, setPreviewResult] = useState(null); const [confirmResult, setConfirmResult] = useState(null); + const [phoneLookupResult, setPhoneLookupResult] = useState(null); + const [phoneBalanceResult, setPhoneBalanceResult] = useState(null); + const [phonePrepareResult, setPhonePrepareResult] = useState(null); + const [phoneConfirmResult, setPhoneConfirmResult] = useState(null); async function invoke( path: string, body: unknown, setResult: (v: ApiResult | null) => void, successMsg: string, + onSuccess?: (res: ApiResult) => void, ) { setLoading(true); setResult(null); @@ -42,10 +51,21 @@ export default function RedeemDebugPage() { }); setResult(res); message.success(successMsg); + onSuccess?.(res); if (path.includes('create-token') && res.token) { previewForm.setFieldsValue({ token: res.token }); confirmForm.setFieldsValue({ token: res.token }); } + if (path.includes('phone/balance') && res.sessionId) { + phonePrepareForm.setFieldsValue({ + storeId: phoneBalanceForm.getFieldValue('storeId'), + sessionId: res.sessionId, + }); + phoneConfirmForm.setFieldsValue({ + storeId: phoneBalanceForm.getFieldValue('storeId'), + sessionId: res.sessionId, + }); + } } catch (e) { message.error(e instanceof Error ? e.message : '调用失败'); } finally { @@ -53,129 +73,311 @@ export default function RedeemDebugPage() { } } + const tokenTabItems = [ + { + key: 'create', + label: '1. 生成核销码', + children: ( + + + +
+ + + + + + + + + + + + + +
+
+ + + + + + +
+ ), + }, + { + key: 'preview', + label: '2. 预览核销', + children: ( + + + +
+ + + + + + + +
+
+ + + + + + +
+ ), + }, + { + key: 'confirm', + label: '3. 确认核销', + children: ( + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ ), + }, + ]; + + const phoneTabItems = [ + { + key: 'phone-lookup', + label: '1. 发送查权益验证码', + children: ( + + + +
+ + + + + + + +
+
+ + + + + + +
+ ), + }, + { + key: 'phone-balance', + label: '2. 验证并查权益', + children: ( + + + +
+ + + + + + + + + + +
+
+ + + + + + +
+ ), + }, + { + key: 'phone-prepare', + label: '3. 发送核销确认码', + children: ( + + + +
+ + + + + + + + + + +
+
+ + + + + + +
+ ), + }, + { + key: 'phone-confirm', + label: '4. 确认核销', + children: ( + + + +
+ + + + + + + + + + +
+
+ + + + + + +
+ ), + }, + ]; + return (
核销调试 - - -
- - - - - - - - - - - - - -
-
- - - - - - - - ), + key: 'token', + label: '扫码核销', + children: , }, { - key: 'preview', - label: '2. 预览核销', - children: ( - - - -
- - - - - - - -
-
- - - - - - -
- ), - }, - { - key: 'confirm', - label: '3. 确认核销', - children: ( - - - -
- - - - - - - - - -
-
- - - - - - -
- ), + key: 'phone', + label: '手机号核销', + children: , }, ]} /> diff --git a/apps/h5-shop/src/App.tsx b/apps/h5-shop/src/App.tsx index 1a44895..27c13b8 100644 --- a/apps/h5-shop/src/App.tsx +++ b/apps/h5-shop/src/App.tsx @@ -4,6 +4,7 @@ import TabLayout from './layouts/TabLayout'; import LoginPage from './pages/LoginPage'; import HomePage from './pages/HomePage'; import RedeemConfirmPage from './pages/RedeemConfirmPage'; +import PhoneRedeemPage from './pages/PhoneRedeemPage'; import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RecordsPage from './pages/RecordsPage'; import StatusPage from './pages/StatusPage'; @@ -15,6 +16,7 @@ export default function App() { } /> } /> + } /> } /> }> } /> diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 64c28ec..98c34f5 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -172,6 +172,10 @@ export default function HomePage() {

{scanning ? '正在打开相机…' : '扫码核销'}

{scanMsg &&

{scanMsg}

} + + smartphone + 手机号核销 +
diff --git a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx new file mode 100644 index 0000000..d38a91b --- /dev/null +++ b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx @@ -0,0 +1,333 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +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 [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(() => { + request>('SHOP_H5', '/shop/store') + .then((s) => { + setStoreName(String(s.name || '当前门店')); + if (s.status && s.status !== 'OPEN') setStoreClosed(true); + }) + .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() { + 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; + } + const value = Number(amount); + if (!Number.isFinite(value) || value <= 0) { + setMsg('请输入有效核销金额'); + return; + } + if (balance && value > balance.totalBalance) { + setMsg('核销金额不能超过可用权益'); + return; + } + setLoading(true); + setMsg(''); + try { + await request('SHOP_H5', '/shop/redeem/phone/prepare', { + method: 'POST', + body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }), + }); + setConfirmCooldown(60); + setStep('confirm'); + setMsg('确认验证码已发送至用户手机,请向用户索取后输入'); + } catch (e) { + setMsg(e instanceof Error ? e.message : '发起核销失败'); + } finally { + setLoading(false); + } + } + + async function confirmRedeem() { + if (!confirmCode.trim()) { + setMsg('请输入确认验证码'); + return; + } + setLoading(true); + setMsg(''); + try { + const result = await request>('SHOP_H5', '/shop/redeem/phone/confirm', { + method: 'POST', + body: JSON.stringify({ + sessionId: balance?.sessionId, + code: confirmCode.trim(), + }), + }); + sessionStorage.setItem('lastRedeemResult', JSON.stringify(result)); + navigate('/redeem/success', { + state: { result, storeName, user: balance?.user }, + }); + } catch (e) { + setMsg(e instanceof Error ? e.message : '核销失败'); + } finally { + setLoading(false); + } + } + + const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—'; + + return ( +
+
+ +

手机号核销

+
+ +
+ {storeClosed && ( +

门店当前未营业,无法核销

+ )} + +
+
+
+ smartphone +
+
+

当前登录核销门店

+

{storeName}

+
+
+ +
+ {step === 'lookup' && ( + <> +
+ + setPhone(e.target.value.replace(/\D/g, ''))} + /> +
+
+ +
+ setLookupCode(e.target.value.replace(/\D/g, ''))} + /> + +
+
+ + + )} + + {step === 'amount' && balance && ( + <> +
+
+ person + 用户 +
+ + {userLabel} + +
+
+

可用好客权益

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

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

+
+ + + + )} + + {msg &&

{msg}

} +
+
+
+
+ ); +} diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index f4892c8..f2fc880 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -639,6 +639,82 @@ max-width: 280px; } +.shop-home-phone-link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 16px; + padding: 10px 20px; + border-radius: 999px; + border: 1px solid rgba(139, 26, 26, 0.2); + background: rgba(255, 255, 255, 0.9); + color: var(--color-heritage-red); + font-size: 14px; + font-weight: 600; + text-decoration: none; +} + +.shop-home-phone-link .material-symbols-outlined { + font-size: 18px; +} + +.shop-phone-field { + margin-bottom: 16px; +} + +.shop-phone-label { + display: block; + margin-bottom: 8px; + font-size: 13px; + color: var(--color-on-surface-variant); +} + +.shop-phone-input { + width: 100%; + padding: 12px 14px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + font-size: 16px; + background: #fff; + box-sizing: border-box; +} + +.shop-phone-code-row { + display: flex; + gap: 8px; +} + +.shop-phone-code-row .shop-phone-input { + flex: 1; +} + +.shop-phone-code-btn { + flex-shrink: 0; + padding: 0 14px; + border: none; + border-radius: 12px; + background: var(--color-heritage-red); + color: #fff; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} + +.shop-phone-code-btn:disabled { + opacity: 0.5; +} + +.shop-phone-link-btn { + display: block; + width: 100%; + margin-top: 12px; + padding: 8px; + border: none; + background: transparent; + color: var(--color-on-surface-variant); + font-size: 14px; +} + .shop-scan-auth-overlay { position: fixed; inset: 0; diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index 3bf977a..1e4447a 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -16,6 +16,8 @@ export interface AppConfig { ossEnabled: boolean; aliyunSmsSignName: string; aliyunSmsTemplateCode: string; + /** 核销确认短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 aliyunSmsTemplateCode */ + aliyunSmsRedeemConfirmTemplateCode: string; aliyunSmsAccessKeyId: string; aliyunSmsAccessKeySecret: string; /** 腾讯位置服务 Key(逆地理编码) */ @@ -45,6 +47,7 @@ export function loadAppConfig(env?: Record): AppConf ossEnabled: e.OSS_ENABLED === 'true', aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '', aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '', + aliyunSmsRedeemConfirmTemplateCode: e.ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE ?? '', aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '', aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '', tencentLbsKey: e.TENCENT_LBS_KEY ?? '', diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index a4ac4b9..cbdece9 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -23,6 +23,10 @@ export enum SmsScene { HQ_LOGIN = 'HQ_LOGIN', BIND_PHONE = 'BIND_PHONE', PARTNER_STAFF_ADD = 'PARTNER_STAFF_ADD', + /** 门店手机号核销:查询用户权益前验证码(发至用户手机) */ + REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP', + /** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */ + REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM', } export enum OrderStatus { @@ -141,3 +145,5 @@ export const REDEEM_TOKEN_TTL_SECONDS = 180; export const SMS_CODE_TTL_SECONDS = 180; /** 核销成功后供用户端轮询结果,略长于 token TTL */ export const REDEEM_RESULT_TTL_SECONDS = 360; +/** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */ +export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600; diff --git a/packages/shared-types/src/redeem.ts b/packages/shared-types/src/redeem.ts index 6cdd55c..f19657b 100644 --- a/packages/shared-types/src/redeem.ts +++ b/packages/shared-types/src/redeem.ts @@ -26,3 +26,21 @@ export interface RedeemRecordDto { settleAmount: number; createdAt: string; } + +export interface RedeemPhoneBalanceDto { + sessionId: string; + totalBalance: number; + maskedPhone: string; + user: { + id: string; + userNo?: string | null; + nickname?: string | null; + phone?: string | null; + }; +} + +export interface RedeemPhonePrepareDto { + sessionId: string; + amount: number; + expireInSeconds: number; +} diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index b4c47b9..e38381b 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -14,6 +14,8 @@ MOCK_SMS_CODE=123456 # MOCK_SMS=false 时必填(可与 OSS 共用 RAM) ALIYUN_SMS_SIGN_NAME= ALIYUN_SMS_TEMPLATE_CODE= +# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE +ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE= ALIYUN_SMS_ACCESS_KEY_ID= ALIYUN_SMS_ACCESS_KEY_SECRET= MOCK_PAY=true diff --git a/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts index 6e4e25e..308d66a 100644 --- a/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts +++ b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts @@ -58,13 +58,24 @@ export class SmsAliyunProvider implements ISmsProvider { return this.client; } + private getTemplateCode(scene: string): string { + if ( + scene === 'REDEEM_PHONE_CONFIRM' && + this.config.aliyunSmsRedeemConfirmTemplateCode + ) { + return this.config.aliyunSmsRedeemConfirmTemplateCode; + } + return this.config.aliyunSmsTemplateCode; + } + async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise { const code = await this.smsCodeStore.generateAndStore(phone, scene); const masked = maskPhone(phone); + const templateCode = this.getTemplateCode(scene); const request = new SendSmsRequest({ phoneNumbers: phone, signName: this.config.aliyunSmsSignName, - templateCode: this.config.aliyunSmsTemplateCode, + templateCode, templateParam: JSON.stringify({ code }), }); @@ -75,7 +86,7 @@ export class SmsAliyunProvider implements ISmsProvider { refId: actorRef?.refId, requestBody: { phone: masked, - templateCode: this.config.aliyunSmsTemplateCode, + templateCode, signName: this.config.aliyunSmsSignName, }, }; diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 97eaffd..e63674a 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -87,6 +87,9 @@ export class AuthService { return ClientApp.PARTNER_H5; case SmsScene.HQ_LOGIN: return ClientApp.HQ_WEB; + case SmsScene.REDEEM_PHONE_LOOKUP: + case SmsScene.REDEEM_PHONE_CONFIRM: + return ClientApp.SHOP_H5; default: return ClientApp.USER_H5; } @@ -129,6 +132,14 @@ export class AuthService { }); return account ? { refType: 'HQ', refId: account.id } : undefined; } + case SmsScene.REDEEM_PHONE_LOOKUP: + case SmsScene.REDEEM_PHONE_CONFIRM: { + const user = await this.prisma.user.findFirst({ + where: { phone, mergedIntoUserId: null, status: 1 }, + select: { id: true }, + }); + return user ? { refType: 'USER', refId: user.id } : undefined; + } default: return undefined; } @@ -259,6 +270,15 @@ export class AuthService { if (existing) throw new BadRequestException('该手机号已被使用'); return; } + if (scene === SmsScene.REDEEM_PHONE_LOOKUP || scene === SmsScene.REDEEM_PHONE_CONFIRM) { + const user = await this.prisma.user.findFirst({ + where: { phone, mergedIntoUserId: null, status: 1 }, + select: { id: true, phoneVerifiedAt: true }, + }); + if (!user) throw new BadRequestException('该手机号未注册好客用户'); + if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销'); + return; + } } async verifySmsCode(phone: string, code: string, scene: SmsScene) { diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts index abdf2ea..57712f3 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts @@ -5,6 +5,10 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta import { AdminRedeemDebugService } from './admin-redeem-debug.service'; import { AdminRedeemDebugCreateTokenDto, + AdminRedeemDebugPhoneBalanceDto, + AdminRedeemDebugPhoneConfirmDto, + AdminRedeemDebugPhonePrepareDto, + AdminRedeemDebugPhoneStoreDto, AdminRedeemDebugStoreTokenDto, } from './dto/admin-mutate.dto'; @@ -42,4 +46,30 @@ export class AdminRedeemDebugController { confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) { return this.service.confirm(dto); } + + @Post('phone/send-lookup-sms') + sendPhoneLookupSms(@Body() dto: AdminRedeemDebugPhoneStoreDto) { + return this.service.sendPhoneLookupSms(dto); + } + + @Post('phone/balance') + phoneBalance(@Body() dto: AdminRedeemDebugPhoneBalanceDto) { + return this.service.phoneBalance(dto); + } + + @Post('phone/prepare') + phonePrepare(@Body() dto: AdminRedeemDebugPhonePrepareDto) { + return this.service.phonePrepare(dto); + } + + @Post('phone/confirm') + @HqOperation({ + action: HqOperationAction.REDEEM_DEBUG_CONFIRM, + refType: 'REDEEM_DEBUG', + batch: true, + includeBody: true, + }) + phoneConfirm(@Body() dto: AdminRedeemDebugPhoneConfirmDto) { + return this.service.phoneConfirm(dto); + } } diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts index b91a2ea..a3ff9ab 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts @@ -3,6 +3,10 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { RedeemService } from '../redeem/redeem.service'; import type { AdminRedeemDebugCreateTokenDto, + AdminRedeemDebugPhoneBalanceDto, + AdminRedeemDebugPhoneConfirmDto, + AdminRedeemDebugPhonePrepareDto, + AdminRedeemDebugPhoneStoreDto, AdminRedeemDebugStoreTokenDto, } from './dto/admin-mutate.dto'; @@ -80,4 +84,24 @@ export class AdminRedeemDebugService { const storeAccountId = await this.resolveStoreAccountId(dto.storeId); return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token }); } + + async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) { + const storeAccountId = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.sendPhoneLookupSms(storeAccountId, dto.phone); + } + + async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) { + const storeAccountId = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.verifyPhoneAndGetBalance(storeAccountId, dto.phone, dto.code); + } + + async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) { + const storeAccountId = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.preparePhoneRedeem(storeAccountId, dto.sessionId, dto.amount); + } + + async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) { + const storeAccountId = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.confirmPhoneRedeem(storeAccountId, dto.sessionId, dto.code); + } } diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 49a4ca4..45293df 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -643,6 +643,51 @@ export class AdminRedeemDebugStoreTokenDto { token: string; } +export class AdminRedeemDebugPhoneStoreDto { + @IsString() + @IsNotEmpty() + storeId: string; + + @IsString() + @IsNotEmpty() + phone: string; +} + +export class AdminRedeemDebugPhoneBalanceDto extends AdminRedeemDebugPhoneStoreDto { + @IsString() + @IsNotEmpty() + code: string; +} + +export class AdminRedeemDebugPhonePrepareDto { + @IsString() + @IsNotEmpty() + storeId: string; + + @IsString() + @IsNotEmpty() + sessionId: string; + + @Type(() => Number) + @IsNumber() + @Min(0.01) + amount: number; +} + +export class AdminRedeemDebugPhoneConfirmDto { + @IsString() + @IsNotEmpty() + storeId: string; + + @IsString() + @IsNotEmpty() + sessionId: string; + + @IsString() + @IsNotEmpty() + code: string; +} + export class UpdateDeliveryDto { @IsOptional() @IsString() 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 new file mode 100644 index 0000000..846a059 --- /dev/null +++ b/server/dukang-api/src/modules/redeem/dto/phone-redeem.dto.ts @@ -0,0 +1,39 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; + +export class RedeemPhoneSendLookupSmsDto { + @IsString() + @IsNotEmpty() + phone: string; +} + +export class RedeemPhoneBalanceDto { + @IsString() + @IsNotEmpty() + phone: string; + + @IsString() + @IsNotEmpty() + code: string; +} + +export class RedeemPhonePrepareDto { + @IsString() + @IsNotEmpty() + sessionId: string; + + @Type(() => Number) + @IsNumber() + @Min(0.01) + amount: number; +} + +export class RedeemPhoneConfirmDto { + @IsString() + @IsNotEmpty() + sessionId: string; + + @IsString() + @IsNotEmpty() + code: string; +} diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts index d841289..7458c2a 100644 --- a/server/dukang-api/src/modules/redeem/redeem.controller.ts +++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts @@ -2,6 +2,12 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co import { RedeemService } from './redeem.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { + RedeemPhoneBalanceDto, + RedeemPhoneConfirmDto, + RedeemPhonePrepareDto, + RedeemPhoneSendLookupSmsDto, +} from './dto/phone-redeem.dto'; @Controller('redeem') @UseGuards(JwtAuthGuard) @@ -52,4 +58,24 @@ export class ShopRedeemController { ) { return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize)); } + + @Post('phone/send-lookup-sms') + sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) { + return this.redeemService.sendPhoneLookupSms(user.actorId, body.phone); + } + + @Post('phone/balance') + phoneBalance(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneBalanceDto) { + return this.redeemService.verifyPhoneAndGetBalance(user.actorId, body.phone, body.code); + } + + @Post('phone/prepare') + phonePrepare(@CurrentUser() user: AuthUser, @Body() body: RedeemPhonePrepareDto) { + return this.redeemService.preparePhoneRedeem(user.actorId, body.sessionId, body.amount); + } + + @Post('phone/confirm') + phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) { + return this.redeemService.confirmPhoneRedeem(user.actorId, body.sessionId, body.code); + } } diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 1bd5678..a403c7f 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -10,13 +10,20 @@ import { validateRedeemAmount, allocateBenefitCoupons, } from '@dukang/domain'; -import { REDEEM_RESULT_TTL_SECONDS, REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types'; +import { + ClientApp, + REDEEM_PHONE_SESSION_TTL_SECONDS, + REDEEM_RESULT_TTL_SECONDS, + REDEEM_TOKEN_TTL_SECONDS, + SmsScene, +} from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AnalyticsService } from '../analytics/analytics.service'; import { SettlementService } from '../settlement/settlement.service'; import { BenefitService } from '../benefit/benefit.service'; +import { AuthService } from '../iam/auth.service'; type TokenPayload = { userId: string; @@ -36,6 +43,16 @@ type RedeemResultPayload = { createdAt: string; }; +type PhoneRedeemSession = { + userId: string; + phone: string; + storeAccountId: string; + storeId: string; + amount?: number; + allocations?: Array<{ couponId: string; amount: number }>; + confirmPrepared?: boolean; +}; + @Injectable() export class RedeemService { constructor( @@ -44,8 +61,307 @@ export class RedeemService { private readonly settlementService: SettlementService, private readonly benefitService: BenefitService, private readonly analyticsService: AnalyticsService, + private readonly authService: AuthService, ) {} + private maskPhoneForStore(phone: string) { + if (phone.length < 7) return phone; + return `${phone.slice(0, 3)}****${phone.slice(-4)}`; + } + + private normalizeMobilePhone(phone: string) { + const normalized = String(phone ?? '').trim(); + if (!/^1\d{10}$/.test(normalized)) { + throw new BadRequestException('手机号格式无效'); + } + return normalized; + } + + private phoneSessionKey(sessionId: string) { + return `redeem:phone-session:${sessionId}`; + } + + private async loadOpenStoreAccount(storeAccountId: bigint) { + const account = await this.prisma.storeAccount.findUniqueOrThrow({ + where: { id: storeAccountId }, + include: { store: true }, + }); + if (account.store.status !== 'OPEN') { + throw new BadRequestException('门店未营业'); + } + return account; + } + + private async resolveUserByPhone(phone: string) { + const user = await this.prisma.user.findFirst({ + where: { phone, mergedIntoUserId: null, status: 1 }, + select: { id: true, userNo: true, phone: true, nickname: true, phoneVerifiedAt: true }, + }); + if (!user) throw new NotFoundException('该手机号未注册好客用户'); + if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销'); + return user; + } + + private async computeDirectAllocations(userId: bigint, amount: number) { + const coupons = await this.prisma.benefitCoupon.findMany({ + where: { userId, status: 'ACTIVE' }, + orderBy: { createdAt: 'asc' }, + }); + const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0); + const result = allocateBenefitCoupons( + coupons.map((c) => ({ + id: c.id.toString(), + balance: Number(c.balance), + createdAt: c.createdAt.getTime(), + })), + amount, + ); + if (!result.ok) throw new BadRequestException(result.message); + const check = validateRedeemAmount(totalBalance, amount); + if (!check.ok) throw new BadRequestException(check.message); + return { allocations: result.allocations, totalBalance }; + } + + private async validateAllocations(allocations: Array<{ couponId: string; amount: number }>) { + for (const alloc of allocations) { + let couponId: bigint; + try { + couponId = BigInt(alloc.couponId); + } catch { + throw new BadRequestException('核销分摊数据异常'); + } + const coupon = await this.prisma.benefitCoupon.findUnique({ where: { id: couponId } }); + if (!coupon) throw new BadRequestException('券不存在'); + const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance)); + if (!check.ok) throw new BadRequestException(check.message); + } + } + + private async executeRedeem( + account: Awaited>, + userId: bigint, + amount: number, + normalizedAllocations: Array<{ couponId: string; amount: number }>, + analyticsExtra?: { channel: 'token' | 'phone'; sessionId?: string; tokenSuffix?: string }, + ) { + const settlementRate = Number(account.store.settlementRate); + const settleAmount = calcRedeemSettleAmount(amount, settlementRate); + + let record; + try { + record = await this.prisma.$transaction(async (tx) => { + await this.benefitService.deductCoupons(tx, normalizedAllocations, 'STORE', account.storeId); + + const redeemRecord = await tx.redeemRecord.create({ + data: { + redeemNo: generateRedeemNo(), + userId, + couponId: BigInt(normalizedAllocations[0].couponId), + storeId: account.storeId, + amount, + settleAmount, + }, + }); + + await this.settlementService.createStorePayout( + redeemRecord.id, + account.storeId, + amount, + settleAmount, + settlementRate, + tx, + ); + + return redeemRecord; + }); + } catch (e) { + if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') { + throw new BadRequestException('核销失败,请重试'); + } + if (e instanceof Error && e.message === 'BENEFIT_ALLOC_INVALID') { + throw new BadRequestException('核销分摊数据异常'); + } + throw e; + } + + const redeemExtra = { + redeemRecordId: record.id.toString(), + storeId: account.storeId.toString(), + amount, + channel: analyticsExtra?.channel ?? 'token', + ...(analyticsExtra?.sessionId ? { sessionId: analyticsExtra.sessionId } : {}), + ...(analyticsExtra?.tokenSuffix ? { tokenSuffix: analyticsExtra.tokenSuffix } : {}), + }; + this.analyticsService.trackStoreOneSafe(account.id, ClientApp.SHOP_H5, { + storeId: account.storeId, + eventName: 'store_redeem_confirm', + refType: 'REDEEM_RECORD', + refId: record.id, + extraJson: { + redeemNo: record.redeemNo, + amount, + userId: userId.toString(), + channel: analyticsExtra?.channel ?? 'token', + }, + }); + this.analyticsService.trackOneSafe(userId, ClientApp.SHOP_H5, { + eventName: 'benefit_redeem_success', + refType: 'STORE', + refId: account.storeId, + extraJson: redeemExtra, + }); + this.analyticsService.trackOneSafe(userId, ClientApp.USER_H5, { + eventName: 'benefit_redeem_success', + refType: 'STORE', + refId: account.storeId, + extraJson: redeemExtra, + }); + + return record; + } + + async sendPhoneLookupSms(storeAccountId: bigint, phone: string) { + const account = await this.loadOpenStoreAccount(storeAccountId); + const normalizedPhone = this.normalizeMobilePhone(phone); + await this.resolveUserByPhone(normalizedPhone); + await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, { + clientApp: ClientApp.SHOP_H5, + }); + this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { + storeId: account.storeId, + eventName: 'store_redeem_phone_lookup_sms', + extraJson: { phone: this.maskPhoneForStore(normalizedPhone) }, + }); + return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) }; + } + + async verifyPhoneAndGetBalance(storeAccountId: bigint, phone: string, code: string) { + const account = await this.loadOpenStoreAccount(storeAccountId); + const normalizedPhone = this.normalizeMobilePhone(phone); + const user = await this.resolveUserByPhone(normalizedPhone); + await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP); + + const coupons = await this.prisma.benefitCoupon.findMany({ + where: { userId: user.id, status: 'ACTIVE' }, + }); + const balance = coupons.reduce((sum, c) => sum + Number(c.balance), 0); + + const sessionId = randomBytes(16).toString('hex'); + await this.redis.setJson( + this.phoneSessionKey(sessionId), + { + userId: user.id.toString(), + phone: normalizedPhone, + storeAccountId: storeAccountId.toString(), + storeId: account.storeId.toString(), + } satisfies PhoneRedeemSession, + REDEEM_PHONE_SESSION_TTL_SECONDS, + ); + + this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { + storeId: account.storeId, + eventName: 'store_redeem_phone_balance', + extraJson: { + phone: this.maskPhoneForStore(normalizedPhone), + totalBalance: balance, + sessionId, + }, + }); + + return serializeBigInt({ + sessionId, + totalBalance: balance, + maskedPhone: this.maskPhoneForStore(normalizedPhone), + user: { + id: user.id, + userNo: user.userNo, + nickname: user.nickname, + phone: this.maskPhoneForStore(normalizedPhone), + }, + }); + } + + private async loadPhoneSession(sessionId: string, storeAccountId: bigint): Promise { + const session = await this.redis.getJson(this.phoneSessionKey(sessionId)); + if (!session) throw new BadRequestException('核销会话已过期,请重新验证手机号'); + if (session.storeAccountId !== storeAccountId.toString()) { + throw new BadRequestException('核销会话无效'); + } + return session; + } + + async preparePhoneRedeem(storeAccountId: bigint, sessionId: string, amount: number) { + const account = await this.loadOpenStoreAccount(storeAccountId); + const session = await this.loadPhoneSession(sessionId, storeAccountId); + const userId = BigInt(session.userId); + const { allocations } = await this.computeDirectAllocations(userId, amount); + + await this.authService.sendSms(session.phone, SmsScene.REDEEM_PHONE_CONFIRM, { + clientApp: ClientApp.SHOP_H5, + }); + + const nextSession: PhoneRedeemSession = { + ...session, + amount, + allocations, + confirmPrepared: true, + }; + await this.redis.setJson( + this.phoneSessionKey(sessionId), + nextSession, + 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(session.phone), + }, + }); + + return { + sessionId, + amount, + expireInSeconds: REDEEM_PHONE_SESSION_TTL_SECONDS, + }; + } + + async confirmPhoneRedeem(storeAccountId: bigint, sessionId: string, code: string) { + const account = await this.loadOpenStoreAccount(storeAccountId); + const session = await this.loadPhoneSession(sessionId, storeAccountId); + if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) { + throw new BadRequestException('请先选择核销金额并发送确认验证码'); + } + + await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM); + + const normalizedAllocations = session.allocations.map((item) => ({ + couponId: String(item.couponId), + amount: Number(item.amount), + })); + const amount = Number(session.amount); + const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0); + if (Math.abs(allocSum - amount) > 0.001) { + throw new BadRequestException('核销分摊数据异常'); + } + await this.validateAllocations(normalizedAllocations); + + const record = await this.executeRedeem( + account, + BigInt(session.userId), + amount, + normalizedAllocations, + { channel: 'phone', sessionId }, + ); + + await this.redis.del(this.phoneSessionKey(sessionId)); + + return serializeBigInt(record); + } + async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) { let allocations: Array<{ couponId: string; amount: number }>; @@ -139,13 +455,7 @@ export class RedeemService { } async previewRedeem(storeAccountId: bigint, token: string) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, - include: { store: true }, - }); - if (account.store.status !== 'OPEN') { - throw new BadRequestException('门店未营业'); - } + const account = await this.loadOpenStoreAccount(storeAccountId); const cached = await this.redis.getJson(`redeem:token:${token}`); if (!cached) throw new BadRequestException('核销码无效或已过期'); @@ -184,13 +494,7 @@ export class RedeemService { } async confirmRedeem(storeAccountId: bigint, body: { token: string }) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, - include: { store: true }, - }); - if (account.store.status !== 'OPEN') { - throw new BadRequestException('门店未营业'); - } + const account = await this.loadOpenStoreAccount(storeAccountId); const cached = await this.redis.getJson(`redeem:token:${body.token}`); if (!cached) throw new BadRequestException('核销码无效或已过期'); @@ -220,61 +524,15 @@ export class RedeemService { throw new BadRequestException('核销码数据异常'); } - for (const alloc of normalizedAllocations) { - let couponId: bigint; - try { - couponId = BigInt(alloc.couponId); - } catch { - throw new BadRequestException('核销码数据异常'); - } - const coupon = await this.prisma.benefitCoupon.findUnique({ - where: { id: couponId }, - }); - if (!coupon) throw new BadRequestException('券不存在'); - const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance)); - if (!check.ok) throw new BadRequestException(check.message); - } + await this.validateAllocations(normalizedAllocations); - const amount = tokenAmount; - const settlementRate = Number(account.store.settlementRate); - const settleAmount = calcRedeemSettleAmount(amount, settlementRate); - - let record; - try { - record = await this.prisma.$transaction(async (tx) => { - await this.benefitService.deductCoupons(tx, normalizedAllocations, 'STORE', account.storeId); - - const redeemRecord = await tx.redeemRecord.create({ - data: { - redeemNo: generateRedeemNo(), - userId: BigInt(cached.userId), - couponId: BigInt(normalizedAllocations[0].couponId), - storeId: account.storeId, - amount, - settleAmount, - }, - }); - - await this.settlementService.createStorePayout( - redeemRecord.id, - account.storeId, - amount, - settleAmount, - settlementRate, - tx, - ); - - return redeemRecord; - }); - } catch (e) { - if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') { - throw new BadRequestException('核销失败,请重试'); - } - if (e instanceof Error && e.message === 'BENEFIT_ALLOC_INVALID') { - throw new BadRequestException('核销码数据异常'); - } - throw e; - } + const record = await this.executeRedeem( + account, + BigInt(cached.userId), + tokenAmount, + normalizedAllocations, + { channel: 'token', tokenSuffix: body.token.slice(-8) }, + ); await this.redis.setJson( `redeem:result:${body.token}`, @@ -282,7 +540,7 @@ export class RedeemService { recordId: record.id.toString(), redeemNo: record.redeemNo, userId: cached.userId, - amount, + amount: tokenAmount, storeId: account.storeId.toString(), storeName: account.store.name, createdAt: record.createdAt.toISOString(), @@ -291,35 +549,6 @@ export class RedeemService { ); await this.redis.del(`redeem:token:${body.token}`); - const redeemExtra = { - redeemRecordId: record.id.toString(), - storeId: account.storeId.toString(), - amount, - }; - this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { - storeId: account.storeId, - eventName: 'store_redeem_confirm', - refType: 'REDEEM_RECORD', - refId: record.id, - extraJson: { - redeemNo: record.redeemNo, - amount, - userId: cached.userId, - }, - }); - this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', { - eventName: 'benefit_redeem_success', - refType: 'STORE', - refId: account.storeId, - extraJson: redeemExtra, - }); - this.analyticsService.trackOneSafe(BigInt(cached.userId), 'USER_H5', { - eventName: 'benefit_redeem_success', - refType: 'STORE', - refId: account.storeId, - extraJson: redeemExtra, - }); - return serializeBigInt(record); }