Merge #11 into dev from dev_jacy
webadmin端优化 * dev_jacy: (3 commits) fix(deploy): harden webhook auto-release for CRLF, git reset, and prisma order 小飞侠接口调通测试 webadmin端 Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/11
This commit is contained in:
@@ -12,6 +12,7 @@ import PartnerAccountsPage from './pages/PartnerAccountsPage';
|
|||||||
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 RedeemDebugPage from './pages/RedeemDebugPage';
|
||||||
import DeliveriesPage from './pages/DeliveriesPage';
|
import DeliveriesPage from './pages/DeliveriesPage';
|
||||||
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||||
import HqAccountsPage from './pages/HqAccountsPage';
|
import HqAccountsPage from './pages/HqAccountsPage';
|
||||||
@@ -57,6 +58,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/debug" element={<RedeemDebugPage />} />
|
||||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
||||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
|
|||||||
@@ -61,6 +61,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/debug', label: '核销调试' },
|
||||||
{ key: '/store-payouts', label: '门店打款' },
|
{ key: '/store-payouts', label: '门店打款' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,16 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
|
|||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||||
const json = await res.json();
|
const text = await res.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error(`接口空响应(HTTP ${res.status} ${res.statusText || ''})`);
|
||||||
|
}
|
||||||
|
let json: { code: number; message?: string; data?: T };
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text) as { code: number; message?: string; data?: T };
|
||||||
|
} catch {
|
||||||
|
throw new Error(`接口返回非 JSON(HTTP ${res.status}): ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
if (json.code === 401) {
|
if (json.code === 401) {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@@ -70,6 +79,8 @@ export type AdminUserRow = {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
phoneVerifiedAt: string | null;
|
phoneVerifiedAt: string | null;
|
||||||
mergedIntoUserId: string | null;
|
mergedIntoUserId: string | null;
|
||||||
|
wxOpenId: string | null;
|
||||||
|
wechatVerified: boolean;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
|
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
|
||||||
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
|
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
XFX: '小飞侠',
|
||||||
|
LOGISTICS: '物流快递',
|
||||||
|
MANUAL: '手动',
|
||||||
|
};
|
||||||
|
|
||||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
PENDING_SHIP: '待发货',
|
PENDING_SHIP: '待发货',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** 商品详情页文案模板(Admin 一键套用) */
|
/** 商品详情页文案模板(Admin 一键套用) */
|
||||||
export const TEMPLATE_MAX_DETAIL_IMAGES = 20;
|
export const TEMPLATE_MAX_DETAIL_IMAGES = 30;
|
||||||
|
|
||||||
export type ProductDetailTemplateContent = {
|
export type ProductDetailTemplateContent = {
|
||||||
storyTitle?: string;
|
storyTitle?: string;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Collapse,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -14,7 +18,20 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||||
import { ORDER_STATUS_LABELS } from '../lib/constants';
|
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
type ShipDefaults = {
|
||||||
|
provider: string;
|
||||||
|
providerLabel: string;
|
||||||
|
fromName: string;
|
||||||
|
fromMobile: string;
|
||||||
|
fromAddress: string;
|
||||||
|
fromAddressDetail: string;
|
||||||
|
fromLng: number;
|
||||||
|
fromLat: number;
|
||||||
|
weight: number;
|
||||||
|
payMode: string;
|
||||||
|
};
|
||||||
|
|
||||||
type OrderDetail = AdminOrderRow & {
|
type OrderDetail = AdminOrderRow & {
|
||||||
receiverAddress?: string;
|
receiverAddress?: string;
|
||||||
@@ -45,12 +62,23 @@ type OrderDetail = AdminOrderRow & {
|
|||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [shipForm] = Form.useForm();
|
||||||
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||||
|
const [shipping, setShipping] = useState(false);
|
||||||
|
|
||||||
|
const selectedOrders = useMemo(
|
||||||
|
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||||
|
[data?.items, selectedRowKeys],
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -71,10 +99,71 @@ export default function OrdersPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
|
const defaults = shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null);
|
||||||
|
if (defaults) {
|
||||||
|
setShipDefaults(defaults);
|
||||||
|
shipForm.setFieldsValue({
|
||||||
|
provider: defaults.provider,
|
||||||
|
weight: defaults.weight,
|
||||||
|
payMode: defaults.payMode,
|
||||||
|
fromName: defaults.fromName,
|
||||||
|
fromMobile: defaults.fromMobile,
|
||||||
|
fromAddress: defaults.fromAddress,
|
||||||
|
fromAddressDetail: defaults.fromAddressDetail,
|
||||||
|
fromLng: defaults.fromLng,
|
||||||
|
fromLat: defaults.fromLat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitShip() {
|
||||||
|
if (!detail) return;
|
||||||
|
const values = await shipForm.validateFields();
|
||||||
|
setShipping(true);
|
||||||
|
try {
|
||||||
|
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('发货成功');
|
||||||
|
setDetail(res);
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '发货失败');
|
||||||
|
} finally {
|
||||||
|
setShipping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchDelete() {
|
||||||
|
if (!selectedRowKeys.length) return;
|
||||||
|
setBatchDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ deleted: number; message: string }>('/admin/orders/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: selectedRowKeys }),
|
||||||
|
});
|
||||||
|
message.success(res.message || `已删除 ${res.deleted} 笔订单`);
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
if (detail && selectedRowKeys.includes(detail.id)) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '批量删除失败');
|
||||||
|
} finally {
|
||||||
|
setBatchDeleting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<AdminOrderRow> = [
|
const columns: ColumnsType<AdminOrderRow> = [
|
||||||
@@ -124,7 +213,16 @@ export default function OrdersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>订单监控</Typography.Title>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => setBatchDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="orderNo" label="订单号">
|
<Form.Item name="orderNo" label="订单号">
|
||||||
<Input placeholder="DK..." allowClear />
|
<Input placeholder="DK..." allowClear />
|
||||||
@@ -149,6 +247,10 @@ export default function OrdersPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -215,11 +317,75 @@ export default function OrdersPage() {
|
|||||||
|
|
||||||
{detail.delivery && (
|
{detail.delivery && (
|
||||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||||
<Descriptions.Item label="provider">{detail.delivery.provider}</Descriptions.Item>
|
<Descriptions.Item label="快递公司">
|
||||||
|
{DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Title level={5}>发货</Typography.Title>
|
||||||
|
<Form form={shipForm} layout="vertical" size="small">
|
||||||
|
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[{ value: 'XFX', label: '小飞侠' }]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: '寄付' },
|
||||||
|
{ value: '2', label: '到付' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
items={[{
|
||||||
|
key: 'from',
|
||||||
|
label: '寄件信息(默认仓库,可修改)',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="fromLng" label="经度">
|
||||||
|
<InputNumber step={0.001} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromLat" label="纬度">
|
||||||
|
<InputNumber step={0.001} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
|
||||||
|
调用小飞侠发货
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||||
@@ -239,6 +405,44 @@ export default function OrdersPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
||||||
|
open={batchDeleteOpen}
|
||||||
|
okText="确认删除"
|
||||||
|
okButtonProps={{ danger: true, loading: batchDeleting }}
|
||||||
|
onOk={() => void confirmBatchDelete()}
|
||||||
|
onCancel={() => setBatchDeleteOpen(false)}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="此操作不可恢复"
|
||||||
|
description="将删除所选订单及其配送单、权益券、核销记录等关联业务数据。订单状态流转等业务日志将保留。"
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 600, y: 280 }}
|
||||||
|
dataSource={selectedOrders}
|
||||||
|
columns={[
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
||||||
|
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type ApiResult = Record<string, unknown>;
|
||||||
|
|
||||||
|
function ResultBox({ data }: { data: ApiResult | null }) {
|
||||||
|
if (!data) return <Typography.Text type="secondary">调用后在此显示结果</Typography.Text>;
|
||||||
|
return (
|
||||||
|
<pre style={{
|
||||||
|
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||||
|
maxHeight: 320, overflow: 'auto', fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RedeemDebugPage() {
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [previewForm] = Form.useForm();
|
||||||
|
const [confirmForm] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [createResult, setCreateResult] = useState<ApiResult | null>(null);
|
||||||
|
const [previewResult, setPreviewResult] = useState<ApiResult | null>(null);
|
||||||
|
const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null);
|
||||||
|
|
||||||
|
async function invoke(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
setResult: (v: ApiResult | null) => void,
|
||||||
|
successMsg: string,
|
||||||
|
) {
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await request<ApiResult>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setResult(res);
|
||||||
|
message.success(successMsg);
|
||||||
|
if (path.includes('create-token') && res.token) {
|
||||||
|
previewForm.setFieldsValue({ token: res.token });
|
||||||
|
confirmForm.setFieldsValue({ token: res.token });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '调用失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>核销调试</Typography.Title>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="仅用于 preV1 联调"
|
||||||
|
description="模拟 C 端生成核销码、门店预览与确认核销。确认核销会真实扣减权益并写入核销记录。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'create',
|
||||||
|
label: '1. 生成核销码',
|
||||||
|
children: (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card size="small" title="参数">
|
||||||
|
<Form form={createForm} layout="vertical">
|
||||||
|
<Form.Item name="userId" label="用户 ID" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="用户表 id" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="couponId" label="指定券 ID(可选)">
|
||||||
|
<Input placeholder="不填则自动分摊" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeId" label="绑定门店 ID(可选)">
|
||||||
|
<Input placeholder="绑定后仅该门店可核销" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
const values = createForm.getFieldsValue();
|
||||||
|
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
生成核销码
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Card size="small" title="响应">
|
||||||
|
<ResultBox data={createResult} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'preview',
|
||||||
|
label: '2. 预览核销',
|
||||||
|
children: (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card size="small" title="参数">
|
||||||
|
<Form form={previewForm} layout="vertical">
|
||||||
|
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
const values = previewForm.getFieldsValue();
|
||||||
|
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Card size="small" title="响应">
|
||||||
|
<ResultBox data={previewResult} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'confirm',
|
||||||
|
label: '3. 确认核销',
|
||||||
|
children: (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card size="small" title="参数">
|
||||||
|
<Form form={confirmForm} layout="vertical">
|
||||||
|
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
const values = confirmForm.getFieldsValue();
|
||||||
|
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认核销
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Card size="small" title="响应">
|
||||||
|
<ResultBox data={confirmResult} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,65 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type AdminUserRow, type Paginated } from '../lib/api';
|
import { request, type AdminUserRow, type Paginated } from '../lib/api';
|
||||||
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
type UserOrderRow = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
payAmount: number;
|
||||||
|
payStatus?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type UserDetail = AdminUserRow & {
|
type UserDetail = AdminUserRow & {
|
||||||
|
wxUnionId?: string | null;
|
||||||
cityPref?: Record<string, unknown> | null;
|
cityPref?: Record<string, unknown> | null;
|
||||||
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
|
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
|
||||||
orders?: Array<{ id: string; orderNo: string; status: string; payAmount: number; createdAt: string }>;
|
orders?: UserOrderRow[];
|
||||||
mergedFromCount?: number;
|
mergedFromCount?: number;
|
||||||
orderCount?: number;
|
|
||||||
addressCount?: number;
|
addressCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BatchDeletePreviewItem = {
|
||||||
|
id: string;
|
||||||
|
userNo: string;
|
||||||
|
nickname: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
hasRisk: boolean;
|
||||||
|
unfinishedOrders: UserOrderRow[];
|
||||||
|
redeemRecords: Array<{
|
||||||
|
id: string;
|
||||||
|
redeemNo: string;
|
||||||
|
amount: number;
|
||||||
|
createdAt: string;
|
||||||
|
payoutStatus: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchDeletePreview = {
|
||||||
|
items: BatchDeletePreviewItem[];
|
||||||
|
hasRisk: boolean;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -33,6 +69,16 @@ export default function UsersPage() {
|
|||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [detail, setDetail] = useState<UserDetail | null>(null);
|
const [detail, setDetail] = useState<UserDetail | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||||
|
const [batchDeleteStep, setBatchDeleteStep] = useState<1 | 2>(1);
|
||||||
|
const [batchPreview, setBatchPreview] = useState<BatchDeletePreview | null>(null);
|
||||||
|
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
const [batchRiskAck, setBatchRiskAck] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -65,6 +111,99 @@ export default function UsersPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDeleteModal() {
|
||||||
|
setDeleteConfirm('');
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!detail) return;
|
||||||
|
if (deleteConfirm !== detail.userNo) {
|
||||||
|
message.error('请输入正确的用户编号以确认删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/users/${detail.id}`, { method: 'DELETE' });
|
||||||
|
message.success('用户已删除(行为日志已保留)');
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
setSelectedRowKeys((keys) => keys.filter((k) => k !== detail.id));
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBatchDeleteModal() {
|
||||||
|
if (!selectedRowKeys.length) return;
|
||||||
|
setBatchDeleteStep(1);
|
||||||
|
setBatchRiskAck(false);
|
||||||
|
setBatchPreview(null);
|
||||||
|
setBatchDeleteOpen(true);
|
||||||
|
setBatchPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<BatchDeletePreview>('/admin/users/batch-delete/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: selectedRowKeys }),
|
||||||
|
});
|
||||||
|
setBatchPreview(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '预检失败');
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
} finally {
|
||||||
|
setBatchPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchDelete(confirmRisk: boolean) {
|
||||||
|
if (!selectedRowKeys.length) return;
|
||||||
|
setBatchDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ deleted: number; message: string }>('/admin/users/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: selectedRowKeys, confirmRisk }),
|
||||||
|
});
|
||||||
|
message.success(res.message || `已删除 ${res.deleted} 名用户`);
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
setBatchPreview(null);
|
||||||
|
if (detail && selectedRowKeys.includes(detail.id)) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '批量删除失败');
|
||||||
|
} finally {
|
||||||
|
setBatchDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchDeleteOk() {
|
||||||
|
if (!batchPreview) return;
|
||||||
|
if (batchPreview.hasRisk && batchDeleteStep === 1) {
|
||||||
|
setBatchDeleteStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void confirmBatchDelete(batchPreview.hasRisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderColumns: ColumnsType<UserOrderRow> = [
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
];
|
||||||
|
|
||||||
const columns: ColumnsType<AdminUserRow> = [
|
const columns: ColumnsType<AdminUserRow> = [
|
||||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||||
@@ -80,6 +219,12 @@ export default function UsersPage() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => (v ? <Tag color="green">已验证</Tag> : <Tag color="orange">访客</Tag>),
|
render: (v) => (v ? <Tag color="green">已验证</Tag> : <Tag color="orange">访客</Tag>),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '微信',
|
||||||
|
dataIndex: 'wechatVerified',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => (v ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'deviceKey',
|
title: 'deviceKey',
|
||||||
dataIndex: 'deviceKey',
|
dataIndex: 'deviceKey',
|
||||||
@@ -117,7 +262,16 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>用户监控</Typography.Title>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => void openBatchDeleteModal()}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="phone" label="手机号">
|
<Form.Item name="phone" label="手机号">
|
||||||
<Input placeholder="模糊搜索" allowClear />
|
<Input placeholder="模糊搜索" allowClear />
|
||||||
@@ -153,7 +307,12 @@ export default function UsersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1200 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -166,7 +325,15 @@ export default function UsersPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Drawer title="用户详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer
|
||||||
|
title="用户详情"
|
||||||
|
width={640}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={detail && (
|
||||||
|
<Button danger onClick={openDeleteModal}>删除用户</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
@@ -177,6 +344,11 @@ export default function UsersPage() {
|
|||||||
<Descriptions.Item label="验手机时间">
|
<Descriptions.Item label="验手机时间">
|
||||||
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="微信验证">
|
||||||
|
{detail.wechatVerified ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="wxOpenId">{detail.wxOpenId || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="wxUnionId">{detail.wxUnionId || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="合并至">
|
<Descriptions.Item label="合并至">
|
||||||
{detail.mergedInto
|
{detail.mergedInto
|
||||||
@@ -189,22 +361,20 @@ export default function UsersPage() {
|
|||||||
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{detail.orders && detail.orders.length > 0 && (
|
|
||||||
<>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>最近订单</Typography.Title>
|
全部订单({detail.orders?.length ?? 0})
|
||||||
<Table
|
</Typography.Title>
|
||||||
size="small"
|
<Table
|
||||||
rowKey="id"
|
size="small"
|
||||||
pagination={false}
|
rowKey="id"
|
||||||
dataSource={detail.orders}
|
pagination={false}
|
||||||
columns={[
|
scroll={{ x: 520, y: 240 }}
|
||||||
{ title: '订单号', dataIndex: 'orderNo' },
|
dataSource={detail.orders ?? []}
|
||||||
{ title: '状态', dataIndex: 'status' },
|
columns={orderColumns}
|
||||||
{ title: '金额', dataIndex: 'payAmount', render: (v) => `¥${v}` },
|
locale={{ emptyText: '暂无订单' }}
|
||||||
]}
|
/>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
@@ -215,6 +385,202 @@ export default function UsersPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="确认删除用户"
|
||||||
|
open={deleteOpen}
|
||||||
|
okText="确认删除"
|
||||||
|
okButtonProps={{
|
||||||
|
danger: true,
|
||||||
|
disabled: !detail || deleteConfirm !== detail.userNo,
|
||||||
|
loading: deleting,
|
||||||
|
}}
|
||||||
|
onOk={() => void confirmDelete()}
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="此操作不可恢复"
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
将删除用户 <strong>{detail.userNo}</strong> 及其地址、订单、权益券、核销记录等业务数据。
|
||||||
|
<br />
|
||||||
|
用户行为日志(埋点)与第三方调用日志将<strong>保留</strong>,不随用户删除。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Typography.Text strong>关联订单({detail.orders?.length ?? 0} 笔)</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8, marginBottom: 16 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 520, y: 200 }}
|
||||||
|
dataSource={detail.orders ?? []}
|
||||||
|
columns={orderColumns}
|
||||||
|
locale={{ emptyText: '无订单' }}
|
||||||
|
/>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
请输入用户编号 <Typography.Text code>{detail.userNo}</Typography.Text> 以确认删除:
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
value={deleteConfirm}
|
||||||
|
placeholder={detail.userNo}
|
||||||
|
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={batchDeleteStep === 1 ? `确认批量删除(${selectedRowKeys.length} 人)` : '二次确认:删除关联业务数据'}
|
||||||
|
open={batchDeleteOpen}
|
||||||
|
okText={batchPreview?.hasRisk && batchDeleteStep === 1 ? '下一步' : '确认删除'}
|
||||||
|
okButtonProps={{
|
||||||
|
danger: batchDeleteStep === 2 || !batchPreview?.hasRisk,
|
||||||
|
loading: batchPreviewLoading || batchDeleting,
|
||||||
|
disabled: batchDeleteStep === 2 && !batchRiskAck,
|
||||||
|
}}
|
||||||
|
cancelText={batchDeleteStep === 2 ? '上一步' : '取消'}
|
||||||
|
onOk={() => handleBatchDeleteOk()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (batchDeleteStep === 2) {
|
||||||
|
setBatchDeleteStep(1);
|
||||||
|
setBatchRiskAck(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
width={800}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{batchPreviewLoading && (
|
||||||
|
<Typography.Text type="secondary">正在检查关联订单与核销记录…</Typography.Text>
|
||||||
|
)}
|
||||||
|
{!batchPreviewLoading && batchPreview && batchDeleteStep === 1 && (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type={batchPreview.hasRisk ? 'warning' : 'error'}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={batchPreview.hasRisk ? '部分用户存在未完成订单或核销记录' : '此操作不可恢复'}
|
||||||
|
description={batchPreview.hasRisk
|
||||||
|
? '标有「需关注」的用户名下有未完成订单和/或核销记录。继续后将进入二次确认,确认后将一并删除相关订单、核销记录及权益等业务数据。用户行为日志将保留。'
|
||||||
|
: `将删除 ${batchPreview.total} 名用户及其地址、订单、权益券等业务数据。用户行为日志将保留。`}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 640, y: 320 }}
|
||||||
|
dataSource={batchPreview.items}
|
||||||
|
expandable={{
|
||||||
|
rowExpandable: (row) => row.hasRisk,
|
||||||
|
expandedRowRender: (row) => (
|
||||||
|
<div style={{ padding: '0 8px 8px' }}>
|
||||||
|
{row.unfinishedOrders.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>未完成订单({row.unfinishedOrders.length})</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8, marginBottom: 12 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={row.unfinishedOrders}
|
||||||
|
columns={orderColumns}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.redeemRecords.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>核销记录({row.redeemRecords.length})</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={row.redeemRecords}
|
||||||
|
columns={[
|
||||||
|
{ title: '核销单号', dataIndex: 'redeemNo', width: 160 },
|
||||||
|
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{
|
||||||
|
title: '打款状态',
|
||||||
|
dataIndex: 'payoutStatus',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => (v === 'PENDING' ? <Tag color="orange">待打款</Tag> : v === 'PAID' ? <Tag color="green">已打款</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '风险',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => (row.hasRisk ? (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
<Tag color="warning">需关注</Tag>
|
||||||
|
{row.unfinishedOrders.length > 0 && (
|
||||||
|
<Tag color="orange">未完成订单 {row.unfinishedOrders.length}</Tag>
|
||||||
|
)}
|
||||||
|
{row.redeemRecords.length > 0 && (
|
||||||
|
<Tag color="red">核销 {row.redeemRecords.length}</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Tag color="default">无关联风险</Tag>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!batchPreviewLoading && batchPreview && batchDeleteStep === 2 && (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="即将删除未完成订单与核销记录"
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
以下 <strong>{batchPreview.items.filter((i) => i.hasRisk).length}</strong> 名用户存在未完成订单或核销记录。
|
||||||
|
确认后将<strong>永久删除</strong>这些订单、核销记录、权益券及门店打款关联数据,且不可恢复。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 200 }}
|
||||||
|
dataSource={batchPreview.items.filter((i) => i.hasRisk)}
|
||||||
|
columns={[
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
|
{ title: '未完成订单', width: 110, render: (_, row) => row.unfinishedOrders.length },
|
||||||
|
{ title: '核销记录', width: 90, render: (_, row) => row.redeemRecords.length },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Checkbox checked={batchRiskAck} onChange={(e) => setBatchRiskAck(e.target.checked)}>
|
||||||
|
我确认删除上述用户的未完成订单、核销记录及相关业务数据
|
||||||
|
</Checkbox>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -13,7 +13,7 @@ LOG_FILE="${DEPLOY_LOG_FILE:-/var/log/dukang/deploy.log}"
|
|||||||
|
|
||||||
if [[ -f "$ENV_FILE" ]]; then
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
source "$ENV_FILE"
|
source <(sed 's/\r$//' "$ENV_FILE")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
@@ -49,7 +49,23 @@ if [[ "$LOCAL_SHA" == "$REMOTE_SHA" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "PULL $LOCAL_SHA -> $REMOTE_SHA"
|
log "PULL $LOCAL_SHA -> $REMOTE_SHA"
|
||||||
git pull --ff-only "$GIT_REMOTE" "$GIT_BRANCH"
|
|
||||||
|
# 保留生产密钥,避免 reset 覆盖
|
||||||
|
ENV_BACKUP="$(mktemp)"
|
||||||
|
API_ENV_BACKUP="$(mktemp)"
|
||||||
|
API_ENV_PROD_BACKUP="$(mktemp)"
|
||||||
|
cp "$ENV_FILE" "$ENV_BACKUP" 2>/dev/null || true
|
||||||
|
cp "$APP_ROOT/server/dukang-api/.env" "$API_ENV_BACKUP" 2>/dev/null || true
|
||||||
|
cp "$APP_ROOT/server/dukang-api/.env.production" "$API_ENV_PROD_BACKUP" 2>/dev/null || true
|
||||||
|
|
||||||
|
git checkout "$GIT_BRANCH"
|
||||||
|
git reset --hard "$GIT_REMOTE/$GIT_BRANCH"
|
||||||
|
|
||||||
|
cp "$ENV_BACKUP" "$ENV_FILE" 2>/dev/null || true
|
||||||
|
cp "$API_ENV_BACKUP" "$APP_ROOT/server/dukang-api/.env" 2>/dev/null || true
|
||||||
|
cp "$API_ENV_PROD_BACKUP" "$APP_ROOT/server/dukang-api/.env.production" 2>/dev/null || true
|
||||||
|
rm -f "$ENV_BACKUP" "$API_ENV_BACKUP" "$API_ENV_PROD_BACKUP"
|
||||||
|
sed -i 's/\r$//' "$ENV_FILE" 2>/dev/null || true
|
||||||
|
|
||||||
# 修复 Windows 换行
|
# 修复 Windows 换行
|
||||||
find "$APP_ROOT/deploy" -maxdepth 1 -name '*.sh' -exec sed -i 's/\r$//' {} + 2>/dev/null || true
|
find "$APP_ROOT/deploy" -maxdepth 1 -name '*.sh' -exec sed -i 's/\r$//' {} + 2>/dev/null || true
|
||||||
|
|||||||
@@ -45,15 +45,8 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}"
|
|||||||
pnpm approve-builds --all 2>/dev/null || true
|
pnpm approve-builds --all 2>/dev/null || true
|
||||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||||
|
|
||||||
if [[ "$SKIP_BUILD" == false ]]; then
|
|
||||||
echo "==> 3. 构建"
|
|
||||||
pnpm build
|
|
||||||
else
|
|
||||||
echo "==> 3. 跳过构建"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_DB" == false ]]; then
|
if [[ "$SKIP_DB" == false ]]; then
|
||||||
echo "==> 4. 数据库 schema 同步"
|
echo "==> 3. 数据库 schema 同步(构建前)"
|
||||||
cd "$APP_ROOT/server/dukang-api"
|
cd "$APP_ROOT/server/dukang-api"
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
if [[ "$DB_PUSH_ACCEPT_DATA_LOSS" == true ]]; then
|
if [[ "$DB_PUSH_ACCEPT_DATA_LOSS" == true ]]; then
|
||||||
@@ -65,8 +58,16 @@ if [[ "$SKIP_DB" == false ]]; then
|
|||||||
echo "==> 执行 seed"
|
echo "==> 执行 seed"
|
||||||
pnpm prisma:seed
|
pnpm prisma:seed
|
||||||
fi
|
fi
|
||||||
|
cd "$APP_ROOT"
|
||||||
else
|
else
|
||||||
echo "==> 4. 跳过数据库"
|
echo "==> 3. 跳过数据库"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_BUILD" == false ]]; then
|
||||||
|
echo "==> 4. 构建"
|
||||||
|
pnpm build
|
||||||
|
else
|
||||||
|
echo "==> 4. 跳过构建"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> 5. 重启 PM2"
|
echo "==> 5. 重启 PM2"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
else
|
else
|
||||||
echo " 保留已有 $ENV_FILE"
|
echo " 保留已有 $ENV_FILE"
|
||||||
fi
|
fi
|
||||||
|
sed -i 's/\r$//' "$ENV_FILE" 2>/dev/null || true
|
||||||
|
|
||||||
echo "==> 3. Nginx webhook 路由"
|
echo "==> 3. Nginx webhook 路由"
|
||||||
HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
|
HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
|
||||||
|
|||||||
@@ -67,3 +67,10 @@ XIAOFEIXIA_MCH_ID=
|
|||||||
XIAOFEIXIA_API_KEY=
|
XIAOFEIXIA_API_KEY=
|
||||||
XIAOFEIXIA_SIGN_TYPE=MD5
|
XIAOFEIXIA_SIGN_TYPE=MD5
|
||||||
# XIAOFEIXIA_APP_ID=
|
# XIAOFEIXIA_APP_ID=
|
||||||
|
# HQ 订单发货默认寄件信息
|
||||||
|
SHIP_FROM_NAME=杜康仓库
|
||||||
|
SHIP_FROM_MOBILE=13800000000
|
||||||
|
SHIP_FROM_ADDRESS=河南省郑州市金水区
|
||||||
|
SHIP_FROM_ADDRESS_DETAIL=杜康酒业仓
|
||||||
|
SHIP_FROM_LNG=113.665
|
||||||
|
SHIP_FROM_LAT=34.757
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class CourierConfigService {
|
|||||||
provider,
|
provider,
|
||||||
xiaofeixia: {
|
xiaofeixia: {
|
||||||
apiUrl:
|
apiUrl:
|
||||||
this.config.get<string>('XIAOFEIXIA_API_URL') ??
|
this.config.get<string>('XIAOFEIXIA_API_URL')?.trim() ||
|
||||||
'https://beta.51xiaoju.cn/app/api/interface.do',
|
'https://beta.51xiaoju.cn/app/api/interface.do',
|
||||||
appId: this.config.get<string>('XIAOFEIXIA_APP_ID') || undefined,
|
appId: this.config.get<string>('XIAOFEIXIA_APP_ID') || undefined,
|
||||||
mchId: this.config.get<string>('XIAOFEIXIA_MCH_ID') ?? '',
|
mchId: this.config.get<string>('XIAOFEIXIA_MCH_ID') ?? '',
|
||||||
|
|||||||
@@ -66,7 +66,22 @@ export class XiaofeixiaClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as XiaofeixiaApiResponse<T>;
|
const rawText = await response.text();
|
||||||
|
let payload: XiaofeixiaApiResponse<T>;
|
||||||
|
try {
|
||||||
|
payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse<T>) : (null as unknown as XiaofeixiaApiResponse<T>);
|
||||||
|
} catch {
|
||||||
|
throw new CourierApiError(
|
||||||
|
`小飞侠响应非 JSON(HTTP ${response.status}): ${rawText.slice(0, 200) || '(空)'}`,
|
||||||
|
'200000',
|
||||||
|
'XIAOFEIXIA',
|
||||||
|
rawText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA');
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
|
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
|
||||||
throw new CourierApiError(
|
throw new CourierApiError(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { AdminOrdersService } from './admin-orders.service';
|
import { AdminOrdersService } from './admin-orders.service';
|
||||||
import { UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||||
|
|
||||||
@Controller('admin/orders')
|
@Controller('admin/orders')
|
||||||
@@ -14,11 +15,28 @@ export class AdminOrdersController {
|
|||||||
return this.ordersService.list(query);
|
return this.ordersService.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('batch-delete')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
batchDelete(@Body() dto: BatchDeleteOrdersDto) {
|
||||||
|
return this.ordersService.batchDeleteOrders(dto.ids.map((id) => BigInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ship-defaults')
|
||||||
|
shipDefaults() {
|
||||||
|
return this.ordersService.getShipDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.ordersService.detail(BigInt(id));
|
return this.ordersService.detail(BigInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HQ 发货:调用小飞侠创建运单并更新配送信息 */
|
||||||
|
@Post(':id/ship')
|
||||||
|
ship(@Param('id') id: string, @Body() dto: AdminShipOrderDto) {
|
||||||
|
return this.ordersService.shipOrder(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
/** preV1 调试:直接改订单状态,不走业务校验 */
|
/** preV1 调试:直接改订单状态,不走业务校验 */
|
||||||
@Put(':id/status')
|
@Put(':id/status')
|
||||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
|
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { orderStatusLogWhere } from '../../common/event/event.helpers';
|
import { orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||||
import { TradeService } from '../trade/trade.service';
|
import { TradeService } from '../trade/trade.service';
|
||||||
|
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||||
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||||
|
import type { AdminShipOrderDto } from './dto/admin-mutate.dto';
|
||||||
|
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminOrdersService {
|
export class AdminOrdersService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly tradeService: TradeService,
|
private readonly tradeService: TradeService,
|
||||||
|
private readonly xiaofeixiaService: AdminXiaofeixiaService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: AdminOrdersQueryDto) {
|
async list(query: AdminOrdersQueryDto) {
|
||||||
@@ -87,4 +93,156 @@ export class AdminOrdersService {
|
|||||||
await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
|
await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
|
||||||
return this.detail(id);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getShipDefaults() {
|
||||||
|
return {
|
||||||
|
provider: 'XFX',
|
||||||
|
providerLabel: '小飞侠',
|
||||||
|
fromName: this.config.get<string>('SHIP_FROM_NAME') || '杜康仓库',
|
||||||
|
fromMobile: this.config.get<string>('SHIP_FROM_MOBILE') || '13800000000',
|
||||||
|
fromAddress: this.config.get<string>('SHIP_FROM_ADDRESS') || '河南省郑州市金水区',
|
||||||
|
fromAddressDetail: this.config.get<string>('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓',
|
||||||
|
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
|
||||||
|
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
|
||||||
|
weight: 2,
|
||||||
|
payMode: '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async shipOrder(id: bigint, dto: AdminShipOrderDto) {
|
||||||
|
if (dto.provider !== 'XFX') {
|
||||||
|
throw new BadRequestException('暂仅支持小飞侠配送');
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { delivery: true },
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
|
||||||
|
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||||
|
throw new BadRequestException('当前订单状态不可发货');
|
||||||
|
}
|
||||||
|
if (order.delivery?.trackingNo) {
|
||||||
|
throw new BadRequestException('该订单已有运单号,请勿重复发货');
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = this.getShipDefaults();
|
||||||
|
const shipmentDto: XiaofeixiaCreateShipmentDto = {
|
||||||
|
outNumber: order.orderNo,
|
||||||
|
fromName: dto.fromName || defaults.fromName,
|
||||||
|
fromMobile: dto.fromMobile || defaults.fromMobile,
|
||||||
|
fromAddress: dto.fromAddress || defaults.fromAddress,
|
||||||
|
fromAddressDetail: dto.fromAddressDetail || defaults.fromAddressDetail,
|
||||||
|
fromLng: dto.fromLng ?? defaults.fromLng,
|
||||||
|
fromLat: dto.fromLat ?? defaults.fromLat,
|
||||||
|
toName: order.receiverName,
|
||||||
|
toMobile: order.receiverPhone,
|
||||||
|
toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||||
|
toAddressDetail: order.receiverAddress,
|
||||||
|
goodsName: order.productName,
|
||||||
|
goodsNum: order.quantity,
|
||||||
|
weight: dto.weight ?? defaults.weight,
|
||||||
|
payMode: dto.payMode || defaults.payMode,
|
||||||
|
remark: dto.remark || `HQ发货 ${order.orderNo}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.xiaofeixiaService.createShipment(shipmentDto);
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
throw new BadRequestException(result.error || '小飞侠创建运单失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerShipmentId, trackingNumber } = result.data;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (order.delivery) {
|
||||||
|
await tx.orderDelivery.update({
|
||||||
|
where: { orderId: id },
|
||||||
|
data: {
|
||||||
|
provider: 'XFX',
|
||||||
|
trackingNo: trackingNumber,
|
||||||
|
providerOrderNo: String(providerShipmentId),
|
||||||
|
shippingAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.orderDelivery.create({
|
||||||
|
data: {
|
||||||
|
orderId: id,
|
||||||
|
provider: 'XFX',
|
||||||
|
trackingNo: trackingNumber,
|
||||||
|
providerOrderNo: String(providerShipmentId),
|
||||||
|
shippingAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP');
|
||||||
|
return this.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDeleteOrders(ids: bigint[]) {
|
||||||
|
const uniqueIds = [...new Set(ids)];
|
||||||
|
if (!uniqueIds.length) {
|
||||||
|
return { ok: true, deleted: 0, message: '未选择订单' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = await this.prisma.order.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: { id: true, orderNo: true },
|
||||||
|
});
|
||||||
|
if (!orders.length) throw new NotFoundException('订单不存在');
|
||||||
|
|
||||||
|
const orderIds = orders.map((o) => o.id);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.deleteOrdersInTx(tx, orderIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
deleted: orderIds.length,
|
||||||
|
orderNos: orders.map((o) => o.orderNo),
|
||||||
|
message: '订单及关联业务数据已删除,状态流转等业务日志已保留',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) {
|
||||||
|
if (!orderIds.length) return;
|
||||||
|
|
||||||
|
const couponIds = (
|
||||||
|
await tx.benefitCoupon.findMany({
|
||||||
|
where: { orderId: { in: orderIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((c) => c.id);
|
||||||
|
|
||||||
|
if (couponIds.length) {
|
||||||
|
const redeemIds = (
|
||||||
|
await tx.redeemRecord.findMany({
|
||||||
|
where: { couponId: { in: couponIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((r) => r.id);
|
||||||
|
|
||||||
|
if (redeemIds.length) {
|
||||||
|
await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
|
await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
|
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.order.updateMany({
|
||||||
|
where: { originOrderId: { in: orderIds } },
|
||||||
|
data: { originOrderId: null },
|
||||||
|
});
|
||||||
|
await tx.commonTicket.deleteMany({
|
||||||
|
where: { refType: 'ORDER', refId: { in: orderIds } },
|
||||||
|
});
|
||||||
|
await tx.order.deleteMany({ where: { id: { in: orderIds } } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type TemplateRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_TEMPLATE_DETAIL_IMAGES = 20;
|
const MAX_TEMPLATE_DETAIL_IMAGES = 30;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminProductDetailTemplatesService {
|
export class AdminProductDetailTemplatesService {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||||
|
import type {
|
||||||
|
AdminRedeemDebugCreateTokenDto,
|
||||||
|
AdminRedeemDebugStoreTokenDto,
|
||||||
|
} from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
@Controller('admin/redeem/debug')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminRedeemDebugController {
|
||||||
|
constructor(private readonly service: AdminRedeemDebugService) {}
|
||||||
|
|
||||||
|
/** preV1 调试:为用户生成核销码 */
|
||||||
|
@Post('create-token')
|
||||||
|
createToken(@Body() dto: AdminRedeemDebugCreateTokenDto) {
|
||||||
|
return this.service.createToken(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preV1 调试:门店侧预览核销 */
|
||||||
|
@Post('preview')
|
||||||
|
preview(@Body() dto: AdminRedeemDebugStoreTokenDto) {
|
||||||
|
return this.service.preview(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preV1 调试:门店侧确认核销 */
|
||||||
|
@Post('confirm')
|
||||||
|
confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) {
|
||||||
|
return this.service.confirm(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
|
import type {
|
||||||
|
AdminRedeemDebugCreateTokenDto,
|
||||||
|
AdminRedeemDebugStoreTokenDto,
|
||||||
|
} from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminRedeemDebugService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly redeemService: RedeemService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
|
||||||
|
const account = await this.prisma.storeAccount.findFirst({
|
||||||
|
where: { storeId: BigInt(storeId), status: 'ACTIVE' },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true, store: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
|
||||||
|
}
|
||||||
|
return account.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
||||||
|
return this.redeemService.createToken(BigInt(dto.userId), {
|
||||||
|
amount: dto.amount,
|
||||||
|
couponId: dto.couponId,
|
||||||
|
storeId: dto.storeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async preview(dto: AdminRedeemDebugStoreTokenDto) {
|
||||||
|
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||||
|
return this.redeemService.previewRedeem(storeAccountId, dto.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
|
||||||
|
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||||
|
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { AdminUsersService } from './admin-users.service';
|
import { AdminUsersService } from './admin-users.service';
|
||||||
import { AdminUsersQueryDto } from './dto/admin-query.dto';
|
import { AdminUsersQueryDto } from './dto/admin-query.dto';
|
||||||
|
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
@Controller('admin/users')
|
@Controller('admin/users')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
@@ -13,8 +15,29 @@ export class AdminUsersController {
|
|||||||
return this.usersService.list(query);
|
return this.usersService.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('batch-delete/preview')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
|
||||||
|
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-delete')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
batchDelete(@Body() dto: BatchDeleteUsersConfirmDto) {
|
||||||
|
return this.usersService.batchDeleteUsers(
|
||||||
|
dto.ids.map((id) => BigInt(id)),
|
||||||
|
dto.confirmRisk,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.usersService.detail(BigInt(id));
|
return this.usersService.detail(BigInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.usersService.deleteUser(BigInt(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,42 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import type { AdminUsersQueryDto } from './dto/admin-query.dto';
|
import type { AdminUsersQueryDto } from './dto/admin-query.dto';
|
||||||
|
|
||||||
|
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
|
||||||
|
|
||||||
|
function mapAdminUserRow(u: {
|
||||||
|
id: bigint;
|
||||||
|
userNo: string;
|
||||||
|
deviceKey: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
phoneVerifiedAt: Date | null;
|
||||||
|
mergedIntoUserId: bigint | null;
|
||||||
|
wxOpenId: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
status: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
_count: { orders: number };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
userNo: u.userNo,
|
||||||
|
deviceKey: u.deviceKey,
|
||||||
|
phone: u.phone,
|
||||||
|
phoneVerifiedAt: u.phoneVerifiedAt,
|
||||||
|
mergedIntoUserId: u.mergedIntoUserId,
|
||||||
|
wxOpenId: u.wxOpenId,
|
||||||
|
wechatVerified: !!u.wxOpenId,
|
||||||
|
nickname: u.nickname,
|
||||||
|
status: u.status,
|
||||||
|
createdAt: u.createdAt,
|
||||||
|
updatedAt: u.updatedAt,
|
||||||
|
orderCount: u._count.orders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminUsersService {
|
export class AdminUsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -33,6 +66,7 @@ export class AdminUsersService {
|
|||||||
phone: true,
|
phone: true,
|
||||||
phoneVerifiedAt: true,
|
phoneVerifiedAt: true,
|
||||||
mergedIntoUserId: true,
|
mergedIntoUserId: true,
|
||||||
|
wxOpenId: true,
|
||||||
nickname: true,
|
nickname: true,
|
||||||
status: true,
|
status: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -44,11 +78,7 @@ export class AdminUsersService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
items: items.map((u) => ({
|
items: items.map((u) => mapAdminUserRow(u)),
|
||||||
...u,
|
|
||||||
orderCount: u._count.orders,
|
|
||||||
_count: undefined,
|
|
||||||
})),
|
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -63,12 +93,12 @@ export class AdminUsersService {
|
|||||||
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
orders: {
|
orders: {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 5,
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
orderNo: true,
|
orderNo: true,
|
||||||
status: true,
|
status: true,
|
||||||
payAmount: true,
|
payAmount: true,
|
||||||
|
payStatus: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -79,10 +109,167 @@ export class AdminUsersService {
|
|||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...user,
|
...user,
|
||||||
|
wechatVerified: !!user.wxOpenId,
|
||||||
mergedFromCount: user._count.mergedFrom,
|
mergedFromCount: user._count.mergedFrom,
|
||||||
orderCount: user._count.orders,
|
orderCount: user._count.orders,
|
||||||
addressCount: user._count.addresses,
|
addressCount: user._count.addresses,
|
||||||
_count: undefined,
|
_count: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async previewBatchDelete(ids: bigint[]) {
|
||||||
|
const uniqueIds = [...new Set(ids)];
|
||||||
|
if (!uniqueIds.length) {
|
||||||
|
return { items: [], hasRisk: false, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userNo: true,
|
||||||
|
nickname: true,
|
||||||
|
phone: true,
|
||||||
|
orders: {
|
||||||
|
where: { status: { notIn: [...FINISHED_ORDER_STATUSES] } },
|
||||||
|
select: { id: true, orderNo: true, status: true, payAmount: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
redeemRecords: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
redeemNo: true,
|
||||||
|
amount: true,
|
||||||
|
createdAt: true,
|
||||||
|
payout: { select: { status: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundIds = new Set(users.map((u) => u.id.toString()));
|
||||||
|
const missing = uniqueIds.filter((id) => !foundIds.has(id.toString()));
|
||||||
|
if (missing.length) {
|
||||||
|
throw new NotFoundException('部分用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = users.map((u) => {
|
||||||
|
const unfinishedOrders = u.orders;
|
||||||
|
const redeemRecords = u.redeemRecords.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
redeemNo: r.redeemNo,
|
||||||
|
amount: r.amount,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
payoutStatus: r.payout?.status ?? null,
|
||||||
|
}));
|
||||||
|
const hasRisk = unfinishedOrders.length > 0 || redeemRecords.length > 0;
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
userNo: u.userNo,
|
||||||
|
nickname: u.nickname,
|
||||||
|
phone: u.phone,
|
||||||
|
unfinishedOrders,
|
||||||
|
redeemRecords,
|
||||||
|
hasRisk,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
items,
|
||||||
|
hasRisk: items.some((i) => i.hasRisk),
|
||||||
|
total: items.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDeleteUsers(ids: bigint[], confirmRisk: boolean) {
|
||||||
|
const preview = await this.previewBatchDelete(ids);
|
||||||
|
if (preview.hasRisk && !confirmRisk) {
|
||||||
|
const riskyUsers = preview.items.filter((i) => i.hasRisk);
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: '所选用户存在未完成订单或核销记录,需二次确认后删除',
|
||||||
|
code: 'USER_DELETE_RISK',
|
||||||
|
riskyUsers: riskyUsers.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
userNo: u.userNo,
|
||||||
|
unfinishedOrderCount: u.unfinishedOrders.length,
|
||||||
|
redeemRecordCount: u.redeemRecords.length,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = preview.items.map((i) => BigInt(String(i.id)));
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const id of userIds) {
|
||||||
|
await this.deleteUserInTx(tx, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
deleted: userIds.length,
|
||||||
|
message: '用户及关联业务数据已删除,行为日志已保留',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(id: bigint) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) throw new NotFoundException('用户不存在');
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.deleteUserInTx(tx, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: '用户及关联业务数据已删除,行为日志已保留',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteUserInTx(tx: Prisma.TransactionClient, id: bigint) {
|
||||||
|
const user = await tx.user.findUnique({ where: { id } });
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
await tx.user.updateMany({
|
||||||
|
where: { mergedIntoUserId: id },
|
||||||
|
data: { mergedIntoUserId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderIds = (
|
||||||
|
await tx.order.findMany({ where: { userId: id }, select: { id: true } })
|
||||||
|
).map((o) => o.id);
|
||||||
|
|
||||||
|
const redeemIds = (
|
||||||
|
await tx.redeemRecord.findMany({ where: { userId: id }, select: { id: true } })
|
||||||
|
).map((r) => r.id);
|
||||||
|
|
||||||
|
if (redeemIds.length) {
|
||||||
|
await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
|
await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
|
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.benefitCoupon.deleteMany({ where: { userId: id } });
|
||||||
|
|
||||||
|
if (orderIds.length) {
|
||||||
|
await tx.order.updateMany({
|
||||||
|
where: { originOrderId: { in: orderIds } },
|
||||||
|
data: { originOrderId: null },
|
||||||
|
});
|
||||||
|
await tx.commonTicket.deleteMany({
|
||||||
|
where: { refType: 'ORDER', refId: { in: orderIds } },
|
||||||
|
});
|
||||||
|
await tx.order.deleteMany({ where: { id: { in: orderIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.avatarResourceId) {
|
||||||
|
await tx.commonResource.updateMany({
|
||||||
|
where: { id: user.avatarResourceId, ownerType: 'USER', ownerId: id },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.user.delete({ where: { id } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,12 @@ export class AdminXiaofeixiaService {
|
|||||||
raw: err.raw,
|
raw: err.raw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw err;
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class XiaofeixiaEstimateFreightDto {
|
export class XiaofeixiaEstimateFreightDto {
|
||||||
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0.01)
|
@Min(0.01)
|
||||||
weight: number;
|
weight: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateStoreStatusDto {
|
export class UpdateStoreStatusDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -307,6 +308,96 @@ export class UpdateOrderStatusDto {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BatchDeleteOrdersDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchDeleteUsersDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchDeleteUsersConfirmDto extends BatchDeleteUsersDto {
|
||||||
|
@IsBoolean()
|
||||||
|
confirmRisk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HQ 订单发货(目前仅小飞侠 XFX) */
|
||||||
|
export class AdminShipOrderDto {
|
||||||
|
@IsIn(['XFX'])
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fromName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fromMobile?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fromAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fromAddressDetail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
fromLng?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
fromLat?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['1', '2'])
|
||||||
|
payMode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminRedeemDebugCreateTokenDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
couponId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminRedeemDebugStoreTokenDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
storeId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateDeliveryDto {
|
export class UpdateDeliveryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
|
|||||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||||
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
|
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
|
||||||
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
|
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
|
||||||
|
import { RedeemModule } from '../redeem/redeem.module';
|
||||||
|
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
|
||||||
|
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
|
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminDashboardController,
|
AdminDashboardController,
|
||||||
AdminUsersController,
|
AdminUsersController,
|
||||||
@@ -56,6 +59,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
|
|||||||
AdminTicketsController,
|
AdminTicketsController,
|
||||||
AdminXiaofeixiaController,
|
AdminXiaofeixiaController,
|
||||||
AdminProductDetailTemplatesController,
|
AdminProductDetailTemplatesController,
|
||||||
|
AdminRedeemDebugController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminDashboardService,
|
AdminDashboardService,
|
||||||
@@ -73,6 +77,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
|
|||||||
AdminTicketsService,
|
AdminTicketsService,
|
||||||
AdminXiaofeixiaService,
|
AdminXiaofeixiaService,
|
||||||
AdminProductDetailTemplatesService,
|
AdminProductDetailTemplatesService,
|
||||||
|
AdminRedeemDebugService,
|
||||||
SuperAdminGuard,
|
SuperAdminGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user