手机号核销确认
This commit is contained in:
@@ -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,12 +73,7 @@ export default function RedeemDebugPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const tokenTabItems = [
|
||||||
<div>
|
|
||||||
<Typography.Title level={4}>核销调试</Typography.Title>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
items={[
|
|
||||||
{
|
{
|
||||||
key: 'create',
|
key: 'create',
|
||||||
label: '1. 生成核销码',
|
label: '1. 生成核销码',
|
||||||
@@ -177,6 +192,193 @@ export default function RedeemDebugPage() {
|
|||||||
</Row>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,69 +524,23 @@ 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}`,
|
||||||
{
|
{
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user