手机号核销确认
This commit is contained in:
@@ -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<ApiResult | null>(null);
|
||||
const [previewResult, setPreviewResult] = 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(
|
||||
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,12 +73,7 @@ export default function RedeemDebugPage() {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销调试</Typography.Title>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
const tokenTabItems = [
|
||||
{
|
||||
key: 'create',
|
||||
label: '1. 生成核销码',
|
||||
@@ -177,6 +192,193 @@ export default function RedeemDebugPage() {
|
||||
</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 (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销调试</Typography.Title>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="token"
|
||||
items={[
|
||||
{
|
||||
key: 'token',
|
||||
label: '扫码核销',
|
||||
children: <Tabs items={tokenTabItems} />,
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: '手机号核销',
|
||||
children: <Tabs items={phoneTabItems} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
@@ -172,6 +172,10 @@ export default function HomePage() {
|
||||
</button>
|
||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</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 className="shop-home-status">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string | undefined>): 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 ?? '',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SmsSendResult> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 }) {
|
||||
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<TokenPayload>(`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<TokenPayload>(`redeem:token:${body.token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
@@ -220,69 +524,23 @@ 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,
|
||||
const record = await this.executeRedeem(
|
||||
account,
|
||||
BigInt(cached.userId),
|
||||
tokenAmount,
|
||||
normalizedAllocations,
|
||||
{ channel: 'token', tokenSuffix: body.token.slice(-8) },
|
||||
);
|
||||
|
||||
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(
|
||||
`redeem:result:${body.token}`,
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user