手机号核销确认

This commit is contained in:
2026-07-12 10:34:58 +08:00
parent 7f031cc4c2
commit d949301bc1
17 changed files with 1285 additions and 215 deletions
+316 -114
View File
@@ -22,16 +22,25 @@ export default function RedeemDebugPage() {
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [previewForm] = Form.useForm(); const [previewForm] = Form.useForm();
const [confirmForm] = 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 [loading, setLoading] = useState(false);
const [createResult, setCreateResult] = useState<ApiResult | null>(null); const [createResult, setCreateResult] = useState<ApiResult | null>(null);
const [previewResult, setPreviewResult] = useState<ApiResult | null>(null); const [previewResult, setPreviewResult] = useState<ApiResult | null>(null);
const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null); const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null);
const [phoneLookupResult, setPhoneLookupResult] = useState<ApiResult | null>(null);
const [phoneBalanceResult, setPhoneBalanceResult] = useState<ApiResult | null>(null);
const [phonePrepareResult, setPhonePrepareResult] = useState<ApiResult | null>(null);
const [phoneConfirmResult, setPhoneConfirmResult] = useState<ApiResult | null>(null);
async function invoke( async function invoke(
path: string, path: string,
body: unknown, body: unknown,
setResult: (v: ApiResult | null) => void, setResult: (v: ApiResult | null) => void,
successMsg: string, successMsg: string,
onSuccess?: (res: ApiResult) => void,
) { ) {
setLoading(true); setLoading(true);
setResult(null); setResult(null);
@@ -42,10 +51,21 @@ export default function RedeemDebugPage() {
}); });
setResult(res); setResult(res);
message.success(successMsg); message.success(successMsg);
onSuccess?.(res);
if (path.includes('create-token') && res.token) { if (path.includes('create-token') && res.token) {
previewForm.setFieldsValue({ token: res.token }); previewForm.setFieldsValue({ token: res.token });
confirmForm.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) { } catch (e) {
message.error(e instanceof Error ? e.message : '调用失败'); message.error(e instanceof Error ? e.message : '调用失败');
} finally { } finally {
@@ -53,129 +73,311 @@ export default function RedeemDebugPage() {
} }
} }
const tokenTabItems = [
{
key: 'create',
label: '1. 生成核销码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={createForm} layout="vertical">
<Form.Item
name="userId"
label="用户"
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
>
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
</Form.Item>
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="couponId" label="指定券 ID(可选)">
<Input placeholder="不填则自动分摊" />
</Form.Item>
<Form.Item name="storeId" label="绑定门店 ID(可选)">
<Input placeholder="绑定后仅该门店可核销" />
</Form.Item>
<Button
type="primary"
loading={loading}
onClick={() => {
void createForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={createResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'preview',
label: '2. 预览核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={previewForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Button
loading={loading}
onClick={() => {
const values = previewForm.getFieldsValue();
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={previewResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'confirm',
label: '3. 确认核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={confirmForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Button
type="primary"
danger
loading={loading}
onClick={() => {
const values = confirmForm.getFieldsValue();
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
}}
>
</Button>
</Space>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={confirmResult} />
</Card>
</Col>
</Row>
),
},
];
const phoneTabItems = [
{
key: 'phone-lookup',
label: '1. 发送查权益验证码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneLookupForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
<Input placeholder="11 位手机号" />
</Form.Item>
<Button
type="primary"
loading={loading}
onClick={() => {
void phoneLookupForm.validateFields().then((values) => {
phoneBalanceForm.setFieldsValue({
storeId: values.storeId,
phone: values.phone,
});
void invoke(
'/admin/redeem/debug/phone/send-lookup-sms',
values,
setPhoneLookupResult,
'查权益验证码已发送',
);
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneLookupResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-balance',
label: '2. 验证并查权益',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneBalanceForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
<Input placeholder="Mock 默认 123456" />
</Form.Item>
<Button
loading={loading}
onClick={() => {
void phoneBalanceForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/phone/balance', values, setPhoneBalanceResult, '权益查询成功');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneBalanceResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-prepare',
label: '3. 发送核销确认码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phonePrepareForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
<Input placeholder="上一步返回的 sessionId" />
</Form.Item>
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
</Form.Item>
<Button
loading={loading}
onClick={() => {
void phonePrepareForm.validateFields().then((values) => {
phoneConfirmForm.setFieldsValue({
storeId: values.storeId,
sessionId: values.sessionId,
});
void invoke('/admin/redeem/debug/phone/prepare', values, setPhonePrepareResult, '核销确认码已发送');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phonePrepareResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-confirm',
label: '4. 确认核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneConfirmForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
<Input placeholder="Mock 默认 123456" />
</Form.Item>
<Button
type="primary"
danger
loading={loading}
onClick={() => {
void phoneConfirmForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/phone/confirm', values, setPhoneConfirmResult, '手机号核销成功');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneConfirmResult} />
</Card>
</Col>
</Row>
),
},
];
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Tabs <Tabs
defaultActiveKey="token"
items={[ items={[
{ {
key: 'create', key: 'token',
label: '1. 生成核销', label: '扫码核销',
children: ( children: <Tabs items={tokenTabItems} />,
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={createForm} layout="vertical">
<Form.Item
name="userId"
label="用户"
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
>
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
</Form.Item>
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="couponId" label="指定券 ID(可选)">
<Input placeholder="不填则自动分摊" />
</Form.Item>
<Form.Item name="storeId" label="绑定门店 ID(可选)">
<Input placeholder="绑定后仅该门店可核销" />
</Form.Item>
<Button
type="primary"
loading={loading}
onClick={() => {
void createForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={createResult} />
</Card>
</Col>
</Row>
),
}, },
{ {
key: 'preview', key: 'phone',
label: '2. 预览核销', label: '手机号核销',
children: ( children: <Tabs items={phoneTabItems} />,
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={previewForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Button
loading={loading}
onClick={() => {
const values = previewForm.getFieldsValue();
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={previewResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'confirm',
label: '3. 确认核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={confirmForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Button
type="primary"
danger
loading={loading}
onClick={() => {
const values = confirmForm.getFieldsValue();
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
}}
>
</Button>
</Space>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={confirmResult} />
</Card>
</Col>
</Row>
),
}, },
]} ]}
/> />
+2
View File
@@ -4,6 +4,7 @@ import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage'; import RedeemConfirmPage from './pages/RedeemConfirmPage';
import PhoneRedeemPage from './pages/PhoneRedeemPage';
import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RedeemSuccessPage from './pages/RedeemSuccessPage';
import RecordsPage from './pages/RecordsPage'; import RecordsPage from './pages/RecordsPage';
import StatusPage from './pages/StatusPage'; import StatusPage from './pages/StatusPage';
@@ -15,6 +16,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} /> <Route path="/redeem" element={<RedeemConfirmPage />} />
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} /> <Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
+4
View File
@@ -172,6 +172,10 @@ export default function HomePage() {
</button> </button>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p> <p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>} {scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
<Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span>
</Link>
</section> </section>
<section className="shop-home-status"> <section className="shop-home-status">
+333
View File
@@ -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<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 [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<Record<string, unknown>>('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<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;
}
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<Record<string, unknown>>('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 (
<div className="shop-redeem-page">
<header className="shop-redeem-header">
<button type="button" className="shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title"></h1>
</header>
<main className="shop-redeem-main">
{storeClosed && (
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
)}
<section className="shop-redeem-card">
<div className="shop-redeem-banner">
<div className="shop-redeem-banner-icon">
<span className="material-symbols-outlined shop-fill-icon">smartphone</span>
</div>
<div>
<p className="shop-redeem-banner-label"></p>
<h2 className="shop-redeem-banner-name">{storeName}</h2>
</div>
</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>
</>
)}
{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>
</>
)}
{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>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed}
onClick={() => void confirmRedeem()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('amount');
setConfirmCode('');
}}
>
</button>
</>
)}
{msg && <p className="shop-redeem-error">{msg}</p>}
</div>
</section>
</main>
</div>
);
}
+76
View File
@@ -639,6 +639,82 @@
max-width: 280px; 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 { .shop-scan-auth-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
+3
View File
@@ -16,6 +16,8 @@ export interface AppConfig {
ossEnabled: boolean; ossEnabled: boolean;
aliyunSmsSignName: string; aliyunSmsSignName: string;
aliyunSmsTemplateCode: string; aliyunSmsTemplateCode: string;
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 aliyunSmsTemplateCode */
aliyunSmsRedeemConfirmTemplateCode: string;
aliyunSmsAccessKeyId: string; aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string; aliyunSmsAccessKeySecret: string;
/** 腾讯位置服务 Key(逆地理编码) */ /** 腾讯位置服务 Key(逆地理编码) */
@@ -45,6 +47,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
ossEnabled: e.OSS_ENABLED === 'true', ossEnabled: e.OSS_ENABLED === 'true',
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '', aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '', 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 ?? '', aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '', aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
tencentLbsKey: e.TENCENT_LBS_KEY ?? '', tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
+6
View File
@@ -23,6 +23,10 @@ export enum SmsScene {
HQ_LOGIN = 'HQ_LOGIN', HQ_LOGIN = 'HQ_LOGIN',
BIND_PHONE = 'BIND_PHONE', BIND_PHONE = 'BIND_PHONE',
PARTNER_STAFF_ADD = 'PARTNER_STAFF_ADD', PARTNER_STAFF_ADD = 'PARTNER_STAFF_ADD',
/** 门店手机号核销:查询用户权益前验证码(发至用户手机) */
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
} }
export enum OrderStatus { export enum OrderStatus {
@@ -141,3 +145,5 @@ export const REDEEM_TOKEN_TTL_SECONDS = 180;
export const SMS_CODE_TTL_SECONDS = 180; export const SMS_CODE_TTL_SECONDS = 180;
/** 核销成功后供用户端轮询结果,略长于 token TTL */ /** 核销成功后供用户端轮询结果,略长于 token TTL */
export const REDEEM_RESULT_TTL_SECONDS = 360; export const REDEEM_RESULT_TTL_SECONDS = 360;
/** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */
export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600;
+18
View File
@@ -26,3 +26,21 @@ export interface RedeemRecordDto {
settleAmount: number; settleAmount: number;
createdAt: string; 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;
}
+2
View File
@@ -14,6 +14,8 @@ MOCK_SMS_CODE=123456
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM) # MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
ALIYUN_SMS_SIGN_NAME= ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE= 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_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET= ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true MOCK_PAY=true
@@ -58,13 +58,24 @@ export class SmsAliyunProvider implements ISmsProvider {
return this.client; 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<SmsSendResult> { async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
const code = await this.smsCodeStore.generateAndStore(phone, scene); const code = await this.smsCodeStore.generateAndStore(phone, scene);
const masked = maskPhone(phone); const masked = maskPhone(phone);
const templateCode = this.getTemplateCode(scene);
const request = new SendSmsRequest({ const request = new SendSmsRequest({
phoneNumbers: phone, phoneNumbers: phone,
signName: this.config.aliyunSmsSignName, signName: this.config.aliyunSmsSignName,
templateCode: this.config.aliyunSmsTemplateCode, templateCode,
templateParam: JSON.stringify({ code }), templateParam: JSON.stringify({ code }),
}); });
@@ -75,7 +86,7 @@ export class SmsAliyunProvider implements ISmsProvider {
refId: actorRef?.refId, refId: actorRef?.refId,
requestBody: { requestBody: {
phone: masked, phone: masked,
templateCode: this.config.aliyunSmsTemplateCode, templateCode,
signName: this.config.aliyunSmsSignName, signName: this.config.aliyunSmsSignName,
}, },
}; };
@@ -87,6 +87,9 @@ export class AuthService {
return ClientApp.PARTNER_H5; return ClientApp.PARTNER_H5;
case SmsScene.HQ_LOGIN: case SmsScene.HQ_LOGIN:
return ClientApp.HQ_WEB; return ClientApp.HQ_WEB;
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
default: default:
return ClientApp.USER_H5; return ClientApp.USER_H5;
} }
@@ -129,6 +132,14 @@ export class AuthService {
}); });
return account ? { refType: 'HQ', refId: account.id } : undefined; 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: default:
return undefined; return undefined;
} }
@@ -259,6 +270,15 @@ export class AuthService {
if (existing) throw new BadRequestException('该手机号已被使用'); if (existing) throw new BadRequestException('该手机号已被使用');
return; 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) { async verifySmsCode(phone: string, code: string, scene: SmsScene) {
@@ -5,6 +5,10 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
import { AdminRedeemDebugService } from './admin-redeem-debug.service'; import { AdminRedeemDebugService } from './admin-redeem-debug.service';
import { import {
AdminRedeemDebugCreateTokenDto, AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugPhoneBalanceDto,
AdminRedeemDebugPhoneConfirmDto,
AdminRedeemDebugPhonePrepareDto,
AdminRedeemDebugPhoneStoreDto,
AdminRedeemDebugStoreTokenDto, AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
@@ -42,4 +46,30 @@ export class AdminRedeemDebugController {
confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) { confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.confirm(dto); 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);
}
} }
@@ -3,6 +3,10 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { RedeemService } from '../redeem/redeem.service'; import { RedeemService } from '../redeem/redeem.service';
import type { import type {
AdminRedeemDebugCreateTokenDto, AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugPhoneBalanceDto,
AdminRedeemDebugPhoneConfirmDto,
AdminRedeemDebugPhonePrepareDto,
AdminRedeemDebugPhoneStoreDto,
AdminRedeemDebugStoreTokenDto, AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
@@ -80,4 +84,24 @@ export class AdminRedeemDebugService {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId); const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token }); 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);
}
} }
@@ -643,6 +643,51 @@ export class AdminRedeemDebugStoreTokenDto {
token: string; 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 { export class UpdateDeliveryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -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;
}
@@ -2,6 +2,12 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
import { RedeemService } from './redeem.service'; import { RedeemService } from './redeem.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
RedeemPhoneBalanceDto,
RedeemPhoneConfirmDto,
RedeemPhonePrepareDto,
RedeemPhoneSendLookupSmsDto,
} from './dto/phone-redeem.dto';
@Controller('redeem') @Controller('redeem')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -52,4 +58,24 @@ export class ShopRedeemController {
) { ) {
return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize)); 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);
}
} }
@@ -10,13 +10,20 @@ import {
validateRedeemAmount, validateRedeemAmount,
allocateBenefitCoupons, allocateBenefitCoupons,
} from '@dukang/domain'; } 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 { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service'; import { RedisService } from '../../common/redis/redis.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { SettlementService } from '../settlement/settlement.service'; import { SettlementService } from '../settlement/settlement.service';
import { BenefitService } from '../benefit/benefit.service'; import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service';
type TokenPayload = { type TokenPayload = {
userId: string; userId: string;
@@ -36,6 +43,16 @@ type RedeemResultPayload = {
createdAt: string; createdAt: string;
}; };
type PhoneRedeemSession = {
userId: string;
phone: string;
storeAccountId: string;
storeId: string;
amount?: number;
allocations?: Array<{ couponId: string; amount: number }>;
confirmPrepared?: boolean;
};
@Injectable() @Injectable()
export class RedeemService { export class RedeemService {
constructor( constructor(
@@ -44,8 +61,307 @@ export class RedeemService {
private readonly settlementService: SettlementService, private readonly settlementService: SettlementService,
private readonly benefitService: BenefitService, private readonly benefitService: BenefitService,
private readonly analyticsService: AnalyticsService, 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<ReturnType<RedeemService['loadOpenStoreAccount']>>,
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<PhoneRedeemSession> {
const session = await this.redis.getJson<PhoneRedeemSession>(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 }) { async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
let allocations: Array<{ couponId: string; amount: number }>; let allocations: Array<{ couponId: string; amount: number }>;
@@ -139,13 +455,7 @@ export class RedeemService {
} }
async previewRedeem(storeAccountId: bigint, token: string) { async previewRedeem(storeAccountId: bigint, token: string) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({ const account = await this.loadOpenStoreAccount(storeAccountId);
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`); const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期'); if (!cached) throw new BadRequestException('核销码无效或已过期');
@@ -184,13 +494,7 @@ export class RedeemService {
} }
async confirmRedeem(storeAccountId: bigint, body: { token: string }) { async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({ const account = await this.loadOpenStoreAccount(storeAccountId);
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`); const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期'); if (!cached) throw new BadRequestException('核销码无效或已过期');
@@ -220,61 +524,15 @@ export class RedeemService {
throw new BadRequestException('核销码数据异常'); throw new BadRequestException('核销码数据异常');
} }
for (const alloc of normalizedAllocations) { await this.validateAllocations(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);
}
const amount = tokenAmount; const record = await this.executeRedeem(
const settlementRate = Number(account.store.settlementRate); account,
const settleAmount = calcRedeemSettleAmount(amount, settlementRate); BigInt(cached.userId),
tokenAmount,
let record; normalizedAllocations,
try { { channel: 'token', tokenSuffix: body.token.slice(-8) },
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;
}
await this.redis.setJson( await this.redis.setJson(
`redeem:result:${body.token}`, `redeem:result:${body.token}`,
@@ -282,7 +540,7 @@ export class RedeemService {
recordId: record.id.toString(), recordId: record.id.toString(),
redeemNo: record.redeemNo, redeemNo: record.redeemNo,
userId: cached.userId, userId: cached.userId,
amount, amount: tokenAmount,
storeId: account.storeId.toString(), storeId: account.storeId.toString(),
storeName: account.store.name, storeName: account.store.name,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
@@ -291,35 +549,6 @@ export class RedeemService {
); );
await this.redis.del(`redeem:token:${body.token}`); 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); return serializeBigInt(record);
} }