小飞侠接口调通测试

This commit is contained in:
2026-07-06 17:07:35 +08:00
parent 3625f037d0
commit 4e18763968
8 changed files with 271 additions and 31 deletions
+12 -1
View File
@@ -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(`接口返回非 JSONHTTP ${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;
+143 -20
View File
@@ -1,26 +1,39 @@
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,
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;
}; };
@@ -33,6 +46,9 @@ 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 load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -65,6 +81,44 @@ 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);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleting(false);
}
}
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 +134,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',
@@ -153,7 +213,7 @@ export default function UsersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 1200 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -166,7 +226,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 +245,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 +262,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 +286,58 @@ 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>
</div> </div>
); );
} }
@@ -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(
`小飞侠响应非 JSONHTTP ${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,5 +1,6 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; import { Controller, Delete, Get, Param, 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';
@@ -17,4 +18,10 @@ export class AdminUsersController {
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));
}
} }
@@ -4,6 +4,37 @@ 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';
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 +64,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 +76,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 +91,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 +107,59 @@ 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 deleteUser(id: bigint) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('用户不存在');
await this.prisma.$transaction(async (tx) => {
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 } });
});
return {
ok: true,
message: '用户及关联业务数据已删除,行为日志已保留',
};
}
} }
@@ -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;