弱网核销功能
This commit is contained in:
@@ -10,6 +10,7 @@ import StoreAccountsPage from './pages/StoreAccountsPage';
|
|||||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||||
|
import PendingRedeemPage from './pages/PendingRedeemPage';
|
||||||
import RedeemDebugPage from './pages/RedeemDebugPage';
|
import RedeemDebugPage from './pages/RedeemDebugPage';
|
||||||
import DeliveriesPage from './pages/DeliveriesPage';
|
import DeliveriesPage from './pages/DeliveriesPage';
|
||||||
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||||
@@ -75,6 +76,7 @@ export default function App() {
|
|||||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||||
|
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
||||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||||
<Route path="/store-bills" element={<StoreBillsPage />} />
|
<Route path="/store-bills" element={<StoreBillsPage />} />
|
||||||
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} />
|
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} />
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/benefit/coupons', label: '权益券' },
|
{ key: '/benefit/coupons', label: '权益券' },
|
||||||
{ key: '/benefit/ledgers', label: '流水' },
|
{ key: '/benefit/ledgers', label: '流水' },
|
||||||
{ key: '/redeem-records', label: '核销记录' },
|
{ key: '/redeem-records', label: '核销记录' },
|
||||||
|
{ key: '/redeem-pending', label: '待处理核销' },
|
||||||
{ key: '/redeem/debug', label: '核销调试' },
|
{ key: '/redeem/debug', label: '核销调试' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
REDEEM_PENDING_STATUS_LABELS,
|
||||||
|
type RedeemPendingItem,
|
||||||
|
type RedeemPendingStatus,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||||
|
PENDING: 'orange',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
REJECTED: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PendingRedeemPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
|
||||||
|
'/admin/redeem-pending',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
|
||||||
|
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
|
||||||
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [acting, setActing] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete() {
|
||||||
|
if (!detail) return;
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
|
||||||
|
message.success('已补核销');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '补核销失败');
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject() {
|
||||||
|
if (!detail || !rejectReason.trim()) {
|
||||||
|
message.error('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/redeem-pending/${detail.id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason: rejectReason.trim() }),
|
||||||
|
});
|
||||||
|
message.success('已驳回');
|
||||||
|
setRejectOpen(false);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '驳回失败');
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<RedeemPendingItem> = [
|
||||||
|
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
||||||
|
{
|
||||||
|
title: '核销码 ID',
|
||||||
|
dataIndex: 'redeemToken',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}…</Typography.Text>,
|
||||||
|
},
|
||||||
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
|
||||||
|
},
|
||||||
|
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s: RedeemPendingStatus) => (
|
||||||
|
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="pendingNo" label="待处理单号">
|
||||||
|
<Input allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="redeemToken" label="核销码 ID">
|
||||||
|
<Input allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeId" label="门店 ID">
|
||||||
|
<Input allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="待处理核销详情"
|
||||||
|
width={560}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={
|
||||||
|
detail?.status === 'PENDING' && (
|
||||||
|
<Space>
|
||||||
|
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" loading={acting} onClick={() => void complete()}>
|
||||||
|
人工补核销
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
{detail.photoUrl && (
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销码 ID">
|
||||||
|
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={STATUS_COLOR[detail.status]}>
|
||||||
|
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">
|
||||||
|
{detail.store?.name || '—'}({detail.store?.id})
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">
|
||||||
|
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联核销单">
|
||||||
|
{detail.redeemRecord?.redeemNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="处理时间">
|
||||||
|
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回待处理单"
|
||||||
|
open={rejectOpen}
|
||||||
|
okText="确认驳回"
|
||||||
|
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
|
||||||
|
onOk={() => void reject()}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder="请填写驳回原因"
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
||||||
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
redeemToken: string;
|
||||||
|
failCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
|
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||||
|
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
setUploading(true);
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
const registered = await uploadRedeemPendingPhoto(file);
|
||||||
|
setPhotoResourceId(registered.id);
|
||||||
|
setPreviewUrl(registered.url);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickPhoto() {
|
||||||
|
setMsg('');
|
||||||
|
if (isWechatEnv()) {
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
await weixinSdk.init();
|
||||||
|
const files = await weixinSdk.chooseImages({
|
||||||
|
count: 1,
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
});
|
||||||
|
if (files?.[0]) {
|
||||||
|
await handleFile(files[0]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const text = e instanceof Error ? e.message : '选图失败';
|
||||||
|
if (!/cancel/i.test(text)) setMsg(text);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPending() {
|
||||||
|
if (!photoResourceId) {
|
||||||
|
setMsg('请先拍摄或上传核销码照片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: redeemToken,
|
||||||
|
photoResourceId,
|
||||||
|
failCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setResult(res);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text: string) {
|
||||||
|
void navigator.clipboard?.writeText(text).then(
|
||||||
|
() => setMsg('已复制'),
|
||||||
|
() => setMsg('复制失败,请手动长按复制'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="shop-weaknet-panel" role="alert">
|
||||||
|
<h3 className="headline-md" style={{ marginBottom: 8 }}>弱网核销兜底</h3>
|
||||||
|
<p className="body-md" style={{ marginBottom: 12, lineHeight: 1.5 }}>
|
||||||
|
扫码核销网络失败已达 {failCount} 次。请记录用户手机号尝试「手机号核销」,
|
||||||
|
或拍下用户核销码照片提交客服人工处理。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="shop-weaknet-token">
|
||||||
|
<span className="label-md text-muted">核销码 ID(追查用)</span>
|
||||||
|
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(redeemToken)}>
|
||||||
|
{redeemToken}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="shop-weaknet-success">
|
||||||
|
<p className="body-md">已提交客服,待处理单号:</p>
|
||||||
|
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(result.pendingNo)}>
|
||||||
|
{result.pendingNo}
|
||||||
|
</button>
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
请告知用户保留核销码编号,客服将尽快人工补核销。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) void handleFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{previewUrl && (
|
||||||
|
<img src={previewUrl} alt="核销码照片预览" className="shop-weaknet-preview" />
|
||||||
|
)}
|
||||||
|
<div className="shop-weaknet-actions">
|
||||||
|
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
||||||
|
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-redeem-confirm-btn"
|
||||||
|
disabled={submitting || !photoResourceId}
|
||||||
|
onClick={() => void submitPending()}
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : '提交客服人工核销'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-btn-outline"
|
||||||
|
onClick={() => navigate('/redeem/phone')}
|
||||||
|
>
|
||||||
|
去手机号核销
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -124,10 +124,19 @@ async function rawRequest<T>(
|
|||||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
|
||||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||||
const json = await res.json();
|
let json: { code: number; message?: string; data?: T };
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status})`) as Error & {
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
if (json.code !== 0) {
|
if (json.code !== 0) {
|
||||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||||
err.status = json.code;
|
err.status = res.status >= 500 ? res.status : json.code;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return json.data as T;
|
return json.data as T;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types';
|
||||||
|
import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
export function isNetworkError(e: unknown): boolean {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
const status = err.status;
|
||||||
|
const message = (err.message ?? String(e)).toLowerCase();
|
||||||
|
if (status != null && status >= 500) return true;
|
||||||
|
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/failed to fetch|network|timeout|timed out|abort|offline|连接|网络|超时/.test(message)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 无 status 的 fetch 失败通常是网络类
|
||||||
|
if (status == null && e instanceof TypeError) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportRedeemFailure(
|
||||||
|
token: string,
|
||||||
|
step: 'preview' | 'confirm',
|
||||||
|
error: unknown,
|
||||||
|
): Promise<RedeemFailureReportResult | null> {
|
||||||
|
const err = error as Error & { status?: number };
|
||||||
|
const errorClass: RedeemErrorClass = isNetworkError(error) ? 'NETWORK' : 'BUSINESS';
|
||||||
|
try {
|
||||||
|
return await request<RedeemFailureReportResult>('SHOP_H5', '/shop/redeem/failures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
errorClass,
|
||||||
|
message: err.message?.slice(0, 200),
|
||||||
|
step,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (errorClass === 'NETWORK') {
|
||||||
|
return {
|
||||||
|
failCount: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
|
thresholdReached: true,
|
||||||
|
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { REDEEM_WEAKNET_FAIL_THRESHOLD };
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { apiBase } from './api';
|
||||||
|
import { getStoreProfile } from './api';
|
||||||
|
|
||||||
|
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
export type UploadFileResult = {
|
||||||
|
url: string;
|
||||||
|
ossKey: string;
|
||||||
|
bucket: string;
|
||||||
|
mock: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisteredResource = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('bizType', bizType);
|
||||||
|
formData.append('mediaType', 'IMAGE');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const headers: Record<string, string> = { 'X-Client-App': 'SHOP_H5' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code === 401) {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
throw new Error('未登录');
|
||||||
|
}
|
||||||
|
if (json.code !== 0) {
|
||||||
|
throw new Error(json.message || '上传失败');
|
||||||
|
}
|
||||||
|
return json.data as UploadFileResult;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||||
|
throw new Error('上传超时,请检查网络后重试');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerResource(upload: UploadFileResult, fileName: string): Promise<RegisteredResource> {
|
||||||
|
const profile = getStoreProfile();
|
||||||
|
if (!profile?.storeId) throw new Error('门店信息缺失,请重新登录');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': 'SHOP_H5',
|
||||||
|
};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/common/resources`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: profile.storeId,
|
||||||
|
bizType: 'REDEEM_PENDING_PHOTO',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossKey: upload.ossKey,
|
||||||
|
url: upload.url,
|
||||||
|
ossBucket: upload.bucket,
|
||||||
|
fileName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) throw new Error(json.message || '登记资源失败');
|
||||||
|
return { id: String(json.data.id), url: String(json.data.url) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传核销码照片并登记为 CommonResource,返回 resourceId */
|
||||||
|
export async function uploadRedeemPendingPhoto(file: File): Promise<RegisteredResource> {
|
||||||
|
const uploaded = await uploadFileToOss(file, 'REDEEM_PENDING_PHOTO');
|
||||||
|
return registerResource(uploaded, file.name || 'redeem-pending.jpg');
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
@@ -23,6 +25,8 @@ export default function RedeemConfirmPage() {
|
|||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [storeClosed, setStoreClosed] = useState(false);
|
const [storeClosed, setStoreClosed] = useState(false);
|
||||||
|
const [failCount, setFailCount] = useState(0);
|
||||||
|
const [showWeakNet, setShowWeakNet] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||||
@@ -52,9 +56,16 @@ export default function RedeemConfirmPage() {
|
|||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
})
|
})
|
||||||
.then(setPreview)
|
.then(setPreview)
|
||||||
.catch((e) => {
|
.catch(async (e) => {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||||
|
const report = await reportRedeemFailure(token, 'preview', e);
|
||||||
|
if (report?.thresholdReached) {
|
||||||
|
setFailCount(report.failCount);
|
||||||
|
setShowWeakNet(true);
|
||||||
|
} else if (report) {
|
||||||
|
setFailCount(report.failCount);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
@@ -78,6 +89,13 @@ export default function RedeemConfirmPage() {
|
|||||||
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||||
|
const report = await reportRedeemFailure(token, 'confirm', e);
|
||||||
|
if (report?.thresholdReached) {
|
||||||
|
setFailCount(report.failCount);
|
||||||
|
setShowWeakNet(true);
|
||||||
|
} else if (report) {
|
||||||
|
setFailCount(report.failCount);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -139,8 +157,10 @@ export default function RedeemConfirmPage() {
|
|||||||
|
|
||||||
<div className="shop-redeem-details">
|
<div className="shop-redeem-details">
|
||||||
<div className="shop-redeem-detail-row">
|
<div className="shop-redeem-detail-row">
|
||||||
<span>券编号</span>
|
<span>核销码 ID</span>
|
||||||
<span>{token ? `…${token.slice(-8)}` : '扫码后显示'}</span>
|
<span style={{ wordBreak: 'break-all', maxWidth: '60%', textAlign: 'right' }}>
|
||||||
|
{token || '扫码后显示'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="shop-redeem-detail-row">
|
<div className="shop-redeem-detail-row">
|
||||||
<span>有效期</span>
|
<span>有效期</span>
|
||||||
@@ -152,26 +172,39 @@ export default function RedeemConfirmPage() {
|
|||||||
<span>仅限指定门店</span>
|
<span>仅限指定门店</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{failCount > 0 && !showWeakNet && (
|
||||||
|
<div className="shop-redeem-detail-row">
|
||||||
|
<span>网络失败</span>
|
||||||
|
<span>{failCount} / 5 次</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||||
|
|
||||||
<button
|
{!showWeakNet && (
|
||||||
type="button"
|
<>
|
||||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
<button
|
||||||
disabled={loading || storeClosed || !preview}
|
type="button"
|
||||||
onClick={confirm}
|
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||||
>
|
disabled={loading || storeClosed || !preview}
|
||||||
<span className="material-symbols-outlined shop-fill-icon">
|
onClick={() => void confirm()}
|
||||||
{loading ? 'sync' : 'check_circle'}
|
>
|
||||||
</span>
|
<span className="material-symbols-outlined shop-fill-icon">
|
||||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
|
{loading ? 'sync' : 'check_circle'}
|
||||||
</button>
|
</span>
|
||||||
|
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
|
||||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
</button>
|
||||||
|
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{showWeakNet && token && (
|
||||||
|
<WeakNetFallbackPanel redeemToken={token} failCount={failCount} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="shop-redeem-ornament">
|
<div className="shop-redeem-ornament">
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--color-heritage-red)' }}>
|
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--color-heritage-red)' }}>
|
||||||
wine_bar
|
wine_bar
|
||||||
|
|||||||
@@ -1203,6 +1203,71 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-weaknet-panel {
|
||||||
|
margin: 0 16px 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(166, 29, 36, 0.06);
|
||||||
|
border: 1px solid rgba(166, 29, 36, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-weaknet-token {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-weaknet-copy {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-all;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed rgba(166, 29, 36, 0.35);
|
||||||
|
background: #fff;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-on-surface, #1c1b1b);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-weaknet-preview {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-weaknet-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-weaknet-success {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(46, 125, 50, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-btn-outline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-heritage-red, #a61d24);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-heritage-red, #a61d24);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-redeem-ornament {
|
.shop-redeem-ornament {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -331,6 +331,21 @@ export default function RedeemPage() {
|
|||||||
<p>待核销金额</p>
|
<p>待核销金额</p>
|
||||||
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
|
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{token && (
|
||||||
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>核销码编号(供追查)</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
padding: '0 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>
|
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>
|
||||||
取消核销
|
取消核销
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
allocateBenefitCoupons,
|
allocateBenefitCoupons,
|
||||||
calcBenefitSummary,
|
calcBenefitSummary,
|
||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
|
classifyRedeemClientError,
|
||||||
|
generateRedeemPendingNo,
|
||||||
|
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
} from './index';
|
} from './index';
|
||||||
|
|
||||||
describe('calcBenefitAmount', () => {
|
describe('calcBenefitAmount', () => {
|
||||||
@@ -120,3 +123,26 @@ describe('calcBenefitSummary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('classifyRedeemClientError', () => {
|
||||||
|
it('treats 5xx and network messages as NETWORK', () => {
|
||||||
|
expect(classifyRedeemClientError({ status: 502 })).toBe('NETWORK');
|
||||||
|
expect(classifyRedeemClientError({ message: 'Failed to fetch' })).toBe('NETWORK');
|
||||||
|
expect(classifyRedeemClientError({ message: '网络超时' })).toBe('NETWORK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats business messages as BUSINESS', () => {
|
||||||
|
expect(classifyRedeemClientError({ status: 400, message: '核销码无效或已过期' })).toBe('BUSINESS');
|
||||||
|
expect(classifyRedeemClientError({ message: '余额不足' })).toBe('BUSINESS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateRedeemPendingNo', () => {
|
||||||
|
it('uses RP prefix', () => {
|
||||||
|
expect(generateRedeemPendingNo().startsWith('RP')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports weaknet threshold of 5', () => {
|
||||||
|
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -111,6 +111,36 @@ export function generateRedeemNo(): string {
|
|||||||
return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateRedeemPendingNo(): string {
|
||||||
|
return `RP${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弱网核销:网络类失败达到该次数后触发兜底 */
|
||||||
|
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||||||
|
|
||||||
|
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||||||
|
|
||||||
|
/** 门店端/服务端共用:区分网络类与业务类核销错误(仅 NETWORK 计入 5 次) */
|
||||||
|
export function classifyRedeemClientError(input: {
|
||||||
|
status?: number | null;
|
||||||
|
message?: string | null;
|
||||||
|
}): RedeemErrorClass {
|
||||||
|
const status = input.status;
|
||||||
|
const message = (input.message ?? '').toLowerCase();
|
||||||
|
if (status != null && status >= 500) return 'NETWORK';
|
||||||
|
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||||
|
return 'NETWORK';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/failed to fetch|network|timeout|timed out|econnreset|econnrefused|etimedout|abort|offline|连接|网络|超时/.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'NETWORK';
|
||||||
|
}
|
||||||
|
return 'BUSINESS';
|
||||||
|
}
|
||||||
|
|
||||||
export function orderTabToStatuses(tab: string): string[] | undefined {
|
export function orderTabToStatuses(tab: string): string[] | undefined {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'pending_pay':
|
case 'pending_pay':
|
||||||
|
|||||||
@@ -180,3 +180,7 @@ export const SMS_CODE_TTL_SECONDS = 180;
|
|||||||
export const REDEEM_RESULT_TTL_SECONDS = 360;
|
export const REDEEM_RESULT_TTL_SECONDS = 360;
|
||||||
/** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */
|
/** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */
|
||||||
export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600;
|
export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600;
|
||||||
|
/** 弱网兜底:preview 快照 TTL(token 失效后仍可提交待处理单) */
|
||||||
|
export const REDEEM_PENDING_SNAPSHOT_TTL_SECONDS = 600;
|
||||||
|
/** 弱网核销:网络类失败阈值 */
|
||||||
|
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||||||
|
|||||||
@@ -44,3 +44,46 @@ export interface RedeemPhonePrepareDto {
|
|||||||
amount: number;
|
amount: number;
|
||||||
expireInSeconds: number;
|
expireInSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||||
|
|
||||||
|
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
|
||||||
|
PENDING: '待处理',
|
||||||
|
COMPLETED: '已补核销',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||||||
|
|
||||||
|
export type RedeemFailureReportResult = {
|
||||||
|
failCount: number;
|
||||||
|
thresholdReached: boolean;
|
||||||
|
threshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedeemPendingSubmitResult = {
|
||||||
|
pendingId: string;
|
||||||
|
pendingNo: string;
|
||||||
|
redeemToken: string;
|
||||||
|
failCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedeemPendingItem = {
|
||||||
|
id: string;
|
||||||
|
pendingNo: string;
|
||||||
|
redeemToken: string;
|
||||||
|
amount: number;
|
||||||
|
redeemType: string;
|
||||||
|
failCount: number;
|
||||||
|
status: RedeemPendingStatus;
|
||||||
|
remark?: string | null;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
processedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
store?: { id: string; name: string; cityName?: string } | null;
|
||||||
|
user?: { id: string; userNo?: string | null; phone?: string | null; nickname?: string | null } | null;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
redeemRecordId?: string | null;
|
||||||
|
redeemRecord?: { id: string; redeemNo: string } | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,18 @@ export type StoreLogCategory =
|
|||||||
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
|
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
|
||||||
login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'],
|
login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'],
|
||||||
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
|
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
|
||||||
redeem: ['store_redeem_preview', 'store_redeem_confirm'],
|
redeem: [
|
||||||
|
'store_redeem_preview',
|
||||||
|
'store_redeem_confirm',
|
||||||
|
'store_redeem_confirm_fail',
|
||||||
|
'store_redeem_weaknet_threshold',
|
||||||
|
'store_redeem_pending_submit',
|
||||||
|
'store_redeem_pending_complete',
|
||||||
|
'store_redeem_pending_reject',
|
||||||
|
'store_redeem_phone_lookup_sms',
|
||||||
|
'store_redeem_phone_balance',
|
||||||
|
'store_redeem_phone_prepare',
|
||||||
|
],
|
||||||
payout: ['store_payout_created', 'store_payout_paid'],
|
payout: ['store_payout_created', 'store_payout_paid'],
|
||||||
store_ops: ['store_status_change'],
|
store_ops: ['store_status_change'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ enum ResourceBizType {
|
|||||||
QRCODE
|
QRCODE
|
||||||
SIGN_PHOTO
|
SIGN_PHOTO
|
||||||
VIDEO
|
VIDEO
|
||||||
|
REDEEM_PENDING_PHOTO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RedeemPendingStatus {
|
||||||
|
PENDING
|
||||||
|
COMPLETED
|
||||||
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResourceMediaType {
|
enum ResourceMediaType {
|
||||||
@@ -280,12 +287,13 @@ model CommonResource {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
productCovers CommonProductItem[] @relation("ProductCover")
|
productCovers CommonProductItem[] @relation("ProductCover")
|
||||||
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
|
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
|
||||||
userAvatars User[] @relation("UserAvatar")
|
userAvatars User[] @relation("UserAvatar")
|
||||||
storeCovers Store[] @relation("StoreCover")
|
storeCovers Store[] @relation("StoreCover")
|
||||||
orderImages Order[] @relation("OrderProductImage")
|
orderImages Order[] @relation("OrderProductImage")
|
||||||
deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
|
deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
|
||||||
|
redeemPendingPhotos RedeemPendingRecord[] @relation("RedeemPendingPhoto")
|
||||||
|
|
||||||
@@index([ownerType, ownerId, bizType])
|
@@index([ownerType, ownerId, bizType])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -606,8 +614,9 @@ model User {
|
|||||||
promoTouch UserPromoAttribution?
|
promoTouch UserPromoAttribution?
|
||||||
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
||||||
orders Order[]
|
orders Order[]
|
||||||
benefitCoupons BenefitCoupon[]
|
benefitCoupons BenefitCoupon[]
|
||||||
redeemRecords RedeemRecord[]
|
redeemRecords RedeemRecord[]
|
||||||
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
|
|
||||||
@@index([sourceType, sourceRefId])
|
@@index([sourceType, sourceRefId])
|
||||||
@@index([referrerUserId])
|
@@index([referrerUserId])
|
||||||
@@ -700,9 +709,10 @@ model Store {
|
|||||||
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||||
account StoreAccount?
|
account StoreAccount?
|
||||||
redeemRecords RedeemRecord[]
|
redeemRecords RedeemRecord[]
|
||||||
ratings StoreRating[]
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
payouts StorePayout[]
|
ratings StoreRating[]
|
||||||
|
payouts StorePayout[]
|
||||||
|
|
||||||
@@index([cityId, status])
|
@@index([cityId, status])
|
||||||
@@index([partnerAccountId])
|
@@index([partnerAccountId])
|
||||||
@@ -721,7 +731,8 @@ model StoreAccount {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
|
|
||||||
@@map("store_account")
|
@@map("store_account")
|
||||||
}
|
}
|
||||||
@@ -854,16 +865,50 @@ model RedeemRecord {
|
|||||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
||||||
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||||
rating StoreRating?
|
rating StoreRating?
|
||||||
payout StorePayout?
|
payout StorePayout?
|
||||||
|
pending RedeemPendingRecord?
|
||||||
|
|
||||||
@@index([storeId, createdAt])
|
@@index([storeId, createdAt])
|
||||||
@@map("user_redeem_record")
|
@@map("user_redeem_record")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RedeemPendingRecord {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
pendingNo String @unique @map("pending_no") @db.VarChar(32)
|
||||||
|
redeemToken String @map("redeem_token") @db.VarChar(64)
|
||||||
|
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||||
|
storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt
|
||||||
|
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
redeemType String @default("DIRECT") @map("redeem_type") @db.VarChar(16)
|
||||||
|
allocationsJson Json @map("allocations_json")
|
||||||
|
photoResourceId BigInt @map("photo_resource_id") @db.UnsignedBigInt
|
||||||
|
failCount Int @default(5) @map("fail_count")
|
||||||
|
status RedeemPendingStatus @default(PENDING)
|
||||||
|
redeemRecordId BigInt? @unique @map("redeem_record_id") @db.UnsignedBigInt
|
||||||
|
processedAt DateTime? @map("processed_at") @db.DateTime(3)
|
||||||
|
processedByHqId BigInt? @map("processed_by_hq_id") @db.UnsignedBigInt
|
||||||
|
rejectReason String? @map("reject_reason") @db.VarChar(256)
|
||||||
|
remark String? @db.VarChar(256)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||||
|
storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Restrict)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
|
photoResource CommonResource @relation("RedeemPendingPhoto", fields: [photoResourceId], references: [id], onDelete: Restrict)
|
||||||
|
redeemRecord RedeemRecord? @relation(fields: [redeemRecordId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([storeId, status, createdAt])
|
||||||
|
@@index([redeemToken])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@map("user_redeem_pending")
|
||||||
|
}
|
||||||
|
|
||||||
model StoreRating {
|
model StoreRating {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
|
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export const HqOperationAction = {
|
|||||||
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
|
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
|
||||||
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
|
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
|
||||||
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
|
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
|
||||||
|
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
|
||||||
|
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
|
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
|
||||||
@@ -105,6 +107,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
||||||
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
|
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
|
||||||
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
|
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
|
||||||
|
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
|
||||||
|
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
|
||||||
STORE_PAYOUT: '门店打款确认',
|
STORE_PAYOUT: '门店打款确认',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,16 @@ export class RedisService {
|
|||||||
async ttl(key: string): Promise<number> {
|
async ttl(key: string): Promise<number> {
|
||||||
return this.redis.ttl(key);
|
return this.redis.ttl(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async incr(key: string, ttlSeconds?: number): Promise<number> {
|
||||||
|
const count = await this.redis.incr(key);
|
||||||
|
if (ttlSeconds && count === 1) {
|
||||||
|
await this.redis.expire(key, ttlSeconds);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
return this.redis.get(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
|
import {
|
||||||
|
AdminRedeemPendingQueryDto,
|
||||||
|
AdminRedeemPendingRejectDto,
|
||||||
|
} from './dto/admin-redeem-pending.dto';
|
||||||
|
|
||||||
|
@Controller('admin/redeem-pending')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminRedeemPendingController {
|
||||||
|
constructor(private readonly redeemService: RedeemService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: AdminRedeemPendingQueryDto) {
|
||||||
|
return this.redeemService.listPendingRedeems(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.redeemService.getPendingRedeem(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/complete')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.REDEEM_PENDING_COMPLETE,
|
||||||
|
refType: 'REDEEM_PENDING',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
complete(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.redeemService.completePendingRedeem(BigInt(id), user.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.REDEEM_PENDING_REJECT,
|
||||||
|
refType: 'REDEEM_PENDING',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
reject(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: AdminRedeemPendingRejectDto,
|
||||||
|
) {
|
||||||
|
return this.redeemService.rejectPendingRedeem(BigInt(id), user.actorId, body.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { PaginationQueryDto } from './admin-query.dto';
|
||||||
|
|
||||||
|
export class AdminRedeemPendingQueryDto extends PaginationQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['PENDING', 'COMPLETED', 'REJECTED'])
|
||||||
|
status?: 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
storeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
pendingNo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redeemToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminRedeemPendingRejectDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
|
|||||||
import { RedeemModule } from '../redeem/redeem.module';
|
import { RedeemModule } from '../redeem/redeem.module';
|
||||||
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
|
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
|
||||||
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||||
|
import { AdminRedeemPendingController } from './admin-redeem-pending.controller';
|
||||||
import { AdminWechatBindingsController } from './admin-wechat-bindings.controller';
|
import { AdminWechatBindingsController } from './admin-wechat-bindings.controller';
|
||||||
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
|
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
|
||||||
import { AdminHqPermissionsController } from './admin-hq-permissions.controller';
|
import { AdminHqPermissionsController } from './admin-hq-permissions.controller';
|
||||||
@@ -77,6 +78,7 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
|||||||
AdminXiaofeixiaController,
|
AdminXiaofeixiaController,
|
||||||
AdminProductDetailTemplatesController,
|
AdminProductDetailTemplatesController,
|
||||||
AdminRedeemDebugController,
|
AdminRedeemDebugController,
|
||||||
|
AdminRedeemPendingController,
|
||||||
AdminWechatBindingsController,
|
AdminWechatBindingsController,
|
||||||
AdminHqPermissionsController,
|
AdminHqPermissionsController,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class RedeemFailureReportDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@IsIn(['NETWORK', 'BUSINESS'])
|
||||||
|
errorClass: 'NETWORK' | 'BUSINESS';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
@IsIn(['preview', 'confirm'])
|
||||||
|
step: 'preview' | 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedeemPendingSubmitDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
photoResourceId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
failCount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
RedeemPhonePrepareDto,
|
RedeemPhonePrepareDto,
|
||||||
RedeemPhoneSendLookupSmsDto,
|
RedeemPhoneSendLookupSmsDto,
|
||||||
} from './dto/phone-redeem.dto';
|
} from './dto/phone-redeem.dto';
|
||||||
|
import { RedeemFailureReportDto, RedeemPendingSubmitDto } from './dto/weaknet-redeem.dto';
|
||||||
|
|
||||||
@Controller('redeem')
|
@Controller('redeem')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -50,6 +51,22 @@ export class ShopRedeemController {
|
|||||||
return this.redeemService.confirmRedeem(user.actorId, body);
|
return this.redeemService.confirmRedeem(user.actorId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('failures')
|
||||||
|
reportFailure(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() body: RedeemFailureReportDto,
|
||||||
|
) {
|
||||||
|
return this.redeemService.reportNetworkFailure(user.actorId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('pending')
|
||||||
|
submitPending(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() body: RedeemPendingSubmitDto,
|
||||||
|
) {
|
||||||
|
return this.redeemService.submitPendingRedeem(user.actorId, body);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('records')
|
@Get('records')
|
||||||
records(
|
records(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
|
|||||||
@@ -7,16 +7,20 @@ import { randomBytes } from 'crypto';
|
|||||||
import {
|
import {
|
||||||
calcRedeemSettleAmount,
|
calcRedeemSettleAmount,
|
||||||
generateRedeemNo,
|
generateRedeemNo,
|
||||||
|
generateRedeemPendingNo,
|
||||||
|
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
allocateBenefitCoupons,
|
allocateBenefitCoupons,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import {
|
import {
|
||||||
ClientApp,
|
ClientApp,
|
||||||
|
REDEEM_PENDING_SNAPSHOT_TTL_SECONDS,
|
||||||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||||||
REDEEM_RESULT_TTL_SECONDS,
|
REDEEM_RESULT_TTL_SECONDS,
|
||||||
REDEEM_TOKEN_TTL_SECONDS,
|
REDEEM_TOKEN_TTL_SECONDS,
|
||||||
SmsScene,
|
SmsScene,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
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';
|
||||||
@@ -33,6 +37,10 @@ type TokenPayload = {
|
|||||||
allocations?: Array<{ couponId: string; amount: number }>;
|
allocations?: Array<{ couponId: string; amount: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingSnapshot = TokenPayload & {
|
||||||
|
redeemType: 'DIRECT' | 'COUPON';
|
||||||
|
};
|
||||||
|
|
||||||
type RedeemResultPayload = {
|
type RedeemResultPayload = {
|
||||||
recordId: string;
|
recordId: string;
|
||||||
redeemNo: string;
|
redeemNo: string;
|
||||||
@@ -470,6 +478,21 @@ export class RedeemService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
||||||
|
const redeemType: 'DIRECT' | 'COUPON' =
|
||||||
|
cached.allocations && cached.allocations.length > 1
|
||||||
|
? 'DIRECT'
|
||||||
|
: cached.couponId
|
||||||
|
? 'COUPON'
|
||||||
|
: 'DIRECT';
|
||||||
|
|
||||||
|
await this.redis.setJson(
|
||||||
|
`redeem:pending-snapshot:${token}`,
|
||||||
|
{
|
||||||
|
...cached,
|
||||||
|
redeemType,
|
||||||
|
} satisfies PendingSnapshot,
|
||||||
|
REDEEM_PENDING_SNAPSHOT_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||||||
storeId: account.storeId,
|
storeId: account.storeId,
|
||||||
@@ -478,7 +501,7 @@ export class RedeemService {
|
|||||||
tokenSuffix: token.slice(-8),
|
tokenSuffix: token.slice(-8),
|
||||||
amount: cached.amount,
|
amount: cached.amount,
|
||||||
userId: cached.userId,
|
userId: cached.userId,
|
||||||
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
|
redeemType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -487,7 +510,7 @@ export class RedeemService {
|
|||||||
amount: cached.amount,
|
amount: cached.amount,
|
||||||
user,
|
user,
|
||||||
boundStoreId: cached.storeId,
|
boundStoreId: cached.storeId,
|
||||||
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
|
redeemType,
|
||||||
expireInSeconds: ttl > 0 ? ttl : 0,
|
expireInSeconds: ttl > 0 ? ttl : 0,
|
||||||
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
|
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
|
||||||
});
|
});
|
||||||
@@ -495,8 +518,20 @@ export class RedeemService {
|
|||||||
|
|
||||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||||
|
const token = body.token?.trim();
|
||||||
|
if (!token) throw new BadRequestException('请提供核销码');
|
||||||
|
|
||||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
|
const existingResult = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
|
||||||
|
if (existingResult) {
|
||||||
|
const record = await this.prisma.redeemRecord.findUnique({
|
||||||
|
where: { id: BigInt(existingResult.recordId) },
|
||||||
|
});
|
||||||
|
if (record) {
|
||||||
|
return serializeBigInt(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||||
|
|
||||||
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
||||||
@@ -531,11 +566,11 @@ export class RedeemService {
|
|||||||
BigInt(cached.userId),
|
BigInt(cached.userId),
|
||||||
tokenAmount,
|
tokenAmount,
|
||||||
normalizedAllocations,
|
normalizedAllocations,
|
||||||
{ channel: 'token', tokenSuffix: body.token.slice(-8) },
|
{ channel: 'token', tokenSuffix: token.slice(-8) },
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.redis.setJson(
|
await this.redis.setJson(
|
||||||
`redeem:result:${body.token}`,
|
`redeem:result:${token}`,
|
||||||
{
|
{
|
||||||
recordId: record.id.toString(),
|
recordId: record.id.toString(),
|
||||||
redeemNo: record.redeemNo,
|
redeemNo: record.redeemNo,
|
||||||
@@ -547,11 +582,365 @@ export class RedeemService {
|
|||||||
} satisfies RedeemResultPayload,
|
} satisfies RedeemResultPayload,
|
||||||
REDEEM_RESULT_TTL_SECONDS,
|
REDEEM_RESULT_TTL_SECONDS,
|
||||||
);
|
);
|
||||||
await this.redis.del(`redeem:token:${body.token}`);
|
await this.redis.del(`redeem:token:${token}`);
|
||||||
|
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
|
||||||
|
|
||||||
return serializeBigInt(record);
|
return serializeBigInt(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private netFailKey(storeAccountId: bigint, token: string) {
|
||||||
|
return `redeem:netfail:${storeAccountId}:${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportNetworkFailure(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
body: {
|
||||||
|
token: string;
|
||||||
|
errorClass: 'NETWORK' | 'BUSINESS';
|
||||||
|
message?: string;
|
||||||
|
step: 'preview' | 'confirm';
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
});
|
||||||
|
const token = body.token.trim();
|
||||||
|
if (!token) throw new BadRequestException('请提供核销码');
|
||||||
|
|
||||||
|
let failCount = 0;
|
||||||
|
if (body.errorClass === 'NETWORK') {
|
||||||
|
failCount = await this.redis.incr(this.netFailKey(storeAccountId, token), REDEEM_TOKEN_TTL_SECONDS);
|
||||||
|
} else {
|
||||||
|
const raw = await this.redis.get(this.netFailKey(storeAccountId, token));
|
||||||
|
failCount = raw ? Number(raw) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD;
|
||||||
|
|
||||||
|
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||||
|
storeId: account.storeId,
|
||||||
|
eventName: 'store_redeem_confirm_fail',
|
||||||
|
extraJson: {
|
||||||
|
token,
|
||||||
|
tokenSuffix: token.slice(-8),
|
||||||
|
errorClass: body.errorClass,
|
||||||
|
failCount,
|
||||||
|
step: body.step,
|
||||||
|
message: body.message?.slice(0, 200) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (thresholdReached && body.errorClass === 'NETWORK') {
|
||||||
|
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||||
|
storeId: account.storeId,
|
||||||
|
eventName: 'store_redeem_weaknet_threshold',
|
||||||
|
extraJson: {
|
||||||
|
token,
|
||||||
|
tokenSuffix: token.slice(-8),
|
||||||
|
failCount,
|
||||||
|
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
failCount,
|
||||||
|
thresholdReached,
|
||||||
|
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePendingSnapshot(token: string): Promise<PendingSnapshot> {
|
||||||
|
const live = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||||||
|
if (live) {
|
||||||
|
return {
|
||||||
|
...live,
|
||||||
|
redeemType:
|
||||||
|
live.allocations && live.allocations.length > 1
|
||||||
|
? 'DIRECT'
|
||||||
|
: live.couponId
|
||||||
|
? 'COUPON'
|
||||||
|
: 'DIRECT',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const snap = await this.redis.getJson<PendingSnapshot>(`redeem:pending-snapshot:${token}`);
|
||||||
|
if (!snap) throw new BadRequestException('核销码已失效且无可用快照,请用户重新出码或改用手机号核销');
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPendingRedeem(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
body: { token: string; photoResourceId: string; failCount?: number; remark?: string },
|
||||||
|
) {
|
||||||
|
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||||
|
const token = body.token.trim();
|
||||||
|
if (!token) throw new BadRequestException('请提供核销码');
|
||||||
|
|
||||||
|
const existingPending = await this.prisma.redeemPendingRecord.findFirst({
|
||||||
|
where: { redeemToken: token, storeId: account.storeId, status: 'PENDING' },
|
||||||
|
});
|
||||||
|
if (existingPending) {
|
||||||
|
return serializeBigInt({
|
||||||
|
pendingId: existingPending.id,
|
||||||
|
pendingNo: existingPending.pendingNo,
|
||||||
|
redeemToken: existingPending.redeemToken,
|
||||||
|
failCount: existingPending.failCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyDone = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
|
||||||
|
if (alreadyDone) {
|
||||||
|
throw new BadRequestException('该核销码已核销成功,无需提交待处理单');
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await this.resolvePendingSnapshot(token);
|
||||||
|
if (snapshot.storeId && snapshot.storeId !== account.storeId.toString()) {
|
||||||
|
throw new BadRequestException('该核销码仅限指定门店使用');
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoId = BigInt(body.photoResourceId);
|
||||||
|
const photo = await this.prisma.commonResource.findFirst({
|
||||||
|
where: { id: photoId, status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
if (!photo) throw new BadRequestException('核销码照片不存在');
|
||||||
|
|
||||||
|
const allocations =
|
||||||
|
snapshot.allocations ??
|
||||||
|
(snapshot.couponId ? [{ couponId: snapshot.couponId, amount: snapshot.amount }] : []);
|
||||||
|
if (!allocations.length) throw new BadRequestException('核销码数据异常');
|
||||||
|
|
||||||
|
const rawFail = await this.redis.get(this.netFailKey(storeAccountId, token));
|
||||||
|
const failCount = Math.max(
|
||||||
|
Number(body.failCount) || 0,
|
||||||
|
rawFail ? Number(rawFail) || 0 : 0,
|
||||||
|
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending = await this.prisma.redeemPendingRecord.create({
|
||||||
|
data: {
|
||||||
|
pendingNo: generateRedeemPendingNo(),
|
||||||
|
redeemToken: token,
|
||||||
|
storeId: account.storeId,
|
||||||
|
storeAccountId: account.id,
|
||||||
|
userId: BigInt(snapshot.userId),
|
||||||
|
amount: snapshot.amount,
|
||||||
|
redeemType: snapshot.redeemType,
|
||||||
|
allocationsJson: allocations as Prisma.InputJsonValue,
|
||||||
|
photoResourceId: photoId,
|
||||||
|
failCount,
|
||||||
|
remark: body.remark?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||||
|
storeId: account.storeId,
|
||||||
|
eventName: 'store_redeem_pending_submit',
|
||||||
|
refType: 'REDEEM_PENDING',
|
||||||
|
refId: pending.id,
|
||||||
|
extraJson: {
|
||||||
|
pendingNo: pending.pendingNo,
|
||||||
|
redeemToken: token,
|
||||||
|
photoResourceId: photoId.toString(),
|
||||||
|
failCount,
|
||||||
|
amount: Number(pending.amount),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
pendingId: pending.id,
|
||||||
|
pendingNo: pending.pendingNo,
|
||||||
|
redeemToken: pending.redeemToken,
|
||||||
|
failCount: pending.failCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPendingRedeems(query: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
status?: 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||||
|
storeId?: string;
|
||||||
|
pendingNo?: string;
|
||||||
|
redeemToken?: string;
|
||||||
|
}) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const where: Prisma.RedeemPendingRecordWhereInput = {};
|
||||||
|
if (query.status) where.status = query.status;
|
||||||
|
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||||
|
if (query.pendingNo) where.pendingNo = { contains: query.pendingNo };
|
||||||
|
if (query.redeemToken) where.redeemToken = { contains: query.redeemToken };
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.redeemPendingRecord.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
store: { select: { id: true, name: true, cityName: true } },
|
||||||
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
|
photoResource: { select: { id: true, url: true } },
|
||||||
|
redeemRecord: { select: { id: true, redeemNo: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.redeemPendingRecord.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
items: items.map((row) => ({
|
||||||
|
...row,
|
||||||
|
photoUrl: row.photoResource?.url ?? null,
|
||||||
|
photoResource: undefined,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingRedeem(id: bigint) {
|
||||||
|
const row = await this.prisma.redeemPendingRecord.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||||
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
|
photoResource: { select: { id: true, url: true } },
|
||||||
|
redeemRecord: { select: { id: true, redeemNo: true, amount: true, settleAmount: true } },
|
||||||
|
storeAccount: { select: { id: true, name: true, phone: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('待处理核销单不存在');
|
||||||
|
return serializeBigInt({
|
||||||
|
...row,
|
||||||
|
photoUrl: row.photoResource?.url ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async completePendingRedeem(pendingId: bigint, hqAccountId: bigint) {
|
||||||
|
const pending = await this.prisma.redeemPendingRecord.findUnique({
|
||||||
|
where: { id: pendingId },
|
||||||
|
});
|
||||||
|
if (!pending) throw new NotFoundException('待处理核销单不存在');
|
||||||
|
if (pending.status === 'COMPLETED' && pending.redeemRecordId) {
|
||||||
|
const record = await this.prisma.redeemRecord.findUnique({
|
||||||
|
where: { id: pending.redeemRecordId },
|
||||||
|
});
|
||||||
|
return serializeBigInt({ pending, record });
|
||||||
|
}
|
||||||
|
if (pending.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('待处理单状态不可补核销');
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
|
where: { id: pending.storeAccountId },
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
if (account.store.status !== 'OPEN') {
|
||||||
|
throw new BadRequestException('门店未营业,无法补核销');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>;
|
||||||
|
const normalizedAllocations = allocationsRaw.map((item) => ({
|
||||||
|
couponId: String(item.couponId),
|
||||||
|
amount: Number(item.amount),
|
||||||
|
}));
|
||||||
|
await this.validateAllocations(normalizedAllocations);
|
||||||
|
|
||||||
|
const amount = Number(pending.amount);
|
||||||
|
const record = await this.executeRedeem(
|
||||||
|
account,
|
||||||
|
pending.userId,
|
||||||
|
amount,
|
||||||
|
normalizedAllocations,
|
||||||
|
{ channel: 'token', tokenSuffix: pending.redeemToken.slice(-8) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const updated = await this.prisma.redeemPendingRecord.update({
|
||||||
|
where: { id: pending.id },
|
||||||
|
data: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
redeemRecordId: record.id,
|
||||||
|
processedAt: now,
|
||||||
|
processedByHqId: hqAccountId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: { select: { id: true, name: true } },
|
||||||
|
user: { select: { id: true, userNo: true, phone: true } },
|
||||||
|
redeemRecord: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.redis.setJson(
|
||||||
|
`redeem:result:${pending.redeemToken}`,
|
||||||
|
{
|
||||||
|
recordId: record.id.toString(),
|
||||||
|
redeemNo: record.redeemNo,
|
||||||
|
userId: pending.userId.toString(),
|
||||||
|
amount,
|
||||||
|
storeId: account.storeId.toString(),
|
||||||
|
storeName: account.store.name,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
} satisfies RedeemResultPayload,
|
||||||
|
REDEEM_RESULT_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
await this.redis.del(`redeem:token:${pending.redeemToken}`);
|
||||||
|
await this.redis.del(`redeem:pending-snapshot:${pending.redeemToken}`);
|
||||||
|
await this.redis.del(this.netFailKey(pending.storeAccountId, pending.redeemToken));
|
||||||
|
|
||||||
|
this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, {
|
||||||
|
storeId: pending.storeId,
|
||||||
|
eventName: 'store_redeem_pending_complete',
|
||||||
|
refType: 'REDEEM_PENDING',
|
||||||
|
refId: pending.id,
|
||||||
|
extraJson: {
|
||||||
|
pendingNo: pending.pendingNo,
|
||||||
|
redeemToken: pending.redeemToken,
|
||||||
|
redeemNo: record.redeemNo,
|
||||||
|
hqAccountId: hqAccountId.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt({ pending: updated, record });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectPendingRedeem(pendingId: bigint, hqAccountId: bigint, reason: string) {
|
||||||
|
const pending = await this.prisma.redeemPendingRecord.findUnique({
|
||||||
|
where: { id: pendingId },
|
||||||
|
});
|
||||||
|
if (!pending) throw new NotFoundException('待处理核销单不存在');
|
||||||
|
if (pending.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('仅待处理状态可驳回');
|
||||||
|
}
|
||||||
|
const rejectReason = reason?.trim();
|
||||||
|
if (!rejectReason) throw new BadRequestException('请填写驳回原因');
|
||||||
|
|
||||||
|
const updated = await this.prisma.redeemPendingRecord.update({
|
||||||
|
where: { id: pendingId },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectReason,
|
||||||
|
processedAt: new Date(),
|
||||||
|
processedByHqId: hqAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, {
|
||||||
|
storeId: pending.storeId,
|
||||||
|
eventName: 'store_redeem_pending_reject',
|
||||||
|
refType: 'REDEEM_PENDING',
|
||||||
|
refId: pending.id,
|
||||||
|
extraJson: {
|
||||||
|
pendingNo: pending.pendingNo,
|
||||||
|
redeemToken: pending.redeemToken,
|
||||||
|
reason: rejectReason,
|
||||||
|
hqAccountId: hqAccountId.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
|
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
|
|||||||
Reference in New Issue
Block a user