From 4e18763968440a264a647690b7331117f0e8d591 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 17:07:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E9=A3=9E=E4=BE=A0=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=B0=83=E9=80=9A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/api.ts | 13 +- apps/admin-web/src/pages/UsersPage.tsx | 163 +++++++++++++++--- .../integrations/courier/courier.config.ts | 2 +- .../courier/xiaofeixia/xiaofeixia.client.ts | 17 +- .../src/modules/ops/admin-users.controller.ts | 9 +- .../src/modules/ops/admin-users.service.ts | 89 +++++++++- .../modules/ops/admin-xiaofeixia.service.ts | 7 +- .../src/modules/ops/dto/admin-courier.dto.ts | 2 + 8 files changed, 271 insertions(+), 31 deletions(-) diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 517d0ba..658e8f3 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -33,7 +33,16 @@ export async function request(path: string, options: RequestInit = {}): Promi if (token) headers.Authorization = `Bearer ${token}`; 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) { clearAuth(); window.location.href = '/login'; @@ -70,6 +79,8 @@ export type AdminUserRow = { phone: string | null; phoneVerifiedAt: string | null; mergedIntoUserId: string | null; + wxOpenId: string | null; + wechatVerified: boolean; nickname: string | null; status: number; createdAt: string; diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 695e172..e087af5 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -1,26 +1,39 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { + Alert, Button, Descriptions, Drawer, Form, Input, + Modal, Select, Space, Table, Tag, Typography, + message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; 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 & { + wxUnionId?: string | null; cityPref?: Record | 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; - orderCount?: number; addressCount?: number; }; @@ -33,6 +46,9 @@ export default function UsersPage() { const [pageSize, setPageSize] = useState(20); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(''); + const [deleting, setDeleting] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -65,6 +81,44 @@ export default function UsersPage() { 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 = [ + { title: '订单号', dataIndex: 'orderNo', width: 170 }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (s) => {ORDER_STATUS_LABELS[s] || s}, + }, + { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, + { title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + ]; + const columns: ColumnsType = [ { title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '昵称', dataIndex: 'nickname', width: 100 }, @@ -80,6 +134,12 @@ export default function UsersPage() { width: 90, render: (v) => (v ? 已验证 : 访客), }, + { + title: '微信', + dataIndex: 'wechatVerified', + width: 100, + render: (v) => (v ? 微信已验证 : 未绑定), + }, { title: 'deviceKey', dataIndex: 'deviceKey', @@ -153,7 +213,7 @@ export default function UsersPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1100 }} + scroll={{ x: 1200 }} pagination={{ current: page, pageSize, @@ -166,7 +226,15 @@ export default function UsersPage() { }} /> - setDrawerOpen(false)}> + setDrawerOpen(false)} + extra={detail && ( + + )} + > {detail && ( <> @@ -177,6 +245,11 @@ export default function UsersPage() { {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} + + {detail.wechatVerified ? 微信已验证 : 未绑定} + + {detail.wxOpenId || '—'} + {detail.wxUnionId || '—'} {detail.deviceKey || '—'} {detail.mergedInto @@ -189,22 +262,20 @@ export default function UsersPage() { {new Date(detail.createdAt).toLocaleString('zh-CN')} - {detail.orders && detail.orders.length > 0 && ( - <> - 最近订单 - `¥${v}` }, - ]} - /> - - )} + + + 全部订单({detail.orders?.length ?? 0}) + +
+
+ + 请输入用户编号 {detail.userNo} 以确认删除: + + setDeleteConfirm(e.target.value)} + /> + + )} + ); } diff --git a/server/dukang-api/src/integrations/courier/courier.config.ts b/server/dukang-api/src/integrations/courier/courier.config.ts index eab15d3..9554bea 100644 --- a/server/dukang-api/src/integrations/courier/courier.config.ts +++ b/server/dukang-api/src/integrations/courier/courier.config.ts @@ -29,7 +29,7 @@ export class CourierConfigService { provider, xiaofeixia: { apiUrl: - this.config.get('XIAOFEIXIA_API_URL') ?? + this.config.get('XIAOFEIXIA_API_URL')?.trim() || 'https://beta.51xiaoju.cn/app/api/interface.do', appId: this.config.get('XIAOFEIXIA_APP_ID') || undefined, mchId: this.config.get('XIAOFEIXIA_MCH_ID') ?? '', diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts index d3c7417..c633050 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts @@ -66,7 +66,22 @@ export class XiaofeixiaClient { ); } - const payload = (await response.json()) as XiaofeixiaApiResponse; + const rawText = await response.text(); + let payload: XiaofeixiaApiResponse; + try { + payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse) : (null as unknown as XiaofeixiaApiResponse); + } 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) { throw new CourierApiError( diff --git a/server/dukang-api/src/modules/ops/admin-users.controller.ts b/server/dukang-api/src/modules/ops/admin-users.controller.ts index b34e4f9..0acc807 100644 --- a/server/dukang-api/src/modules/ops/admin-users.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-users.controller.ts @@ -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 { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import { AdminUsersService } from './admin-users.service'; import { AdminUsersQueryDto } from './dto/admin-query.dto'; @@ -17,4 +18,10 @@ export class AdminUsersController { detail(@Param('id') id: string) { return this.usersService.detail(BigInt(id)); } + + @Delete(':id') + @UseGuards(SuperAdminGuard) + remove(@Param('id') id: string) { + return this.usersService.deleteUser(BigInt(id)); + } } diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts index 24699f0..18d3cff 100644 --- a/server/dukang-api/src/modules/ops/admin-users.service.ts +++ b/server/dukang-api/src/modules/ops/admin-users.service.ts @@ -4,6 +4,37 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; 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() export class AdminUsersService { constructor(private readonly prisma: PrismaService) {} @@ -33,6 +64,7 @@ export class AdminUsersService { phone: true, phoneVerifiedAt: true, mergedIntoUserId: true, + wxOpenId: true, nickname: true, status: true, createdAt: true, @@ -44,11 +76,7 @@ export class AdminUsersService { ]); return serializeBigInt({ - items: items.map((u) => ({ - ...u, - orderCount: u._count.orders, - _count: undefined, - })), + items: items.map((u) => mapAdminUserRow(u)), total, page, pageSize, @@ -63,12 +91,12 @@ export class AdminUsersService { mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } }, orders: { orderBy: { createdAt: 'desc' }, - take: 5, select: { id: true, orderNo: true, status: true, payAmount: true, + payStatus: true, createdAt: true, }, }, @@ -79,10 +107,59 @@ export class AdminUsersService { return serializeBigInt({ ...user, + wechatVerified: !!user.wxOpenId, mergedFromCount: user._count.mergedFrom, orderCount: user._count.orders, addressCount: user._count.addresses, _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: '用户及关联业务数据已删除,行为日志已保留', + }; + } } diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts index 489cadf..4f9bef2 100644 --- a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts +++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts @@ -145,7 +145,12 @@ export class AdminXiaofeixiaService { raw: err.raw, }; } - throw err; + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + elapsedMs: Date.now() - startedAt, + error: message, + }; } } } diff --git a/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts index a5cd347..b11d956 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts @@ -7,8 +7,10 @@ import { IsString, Min, } from 'class-validator'; +import { Type } from 'class-transformer'; export class XiaofeixiaEstimateFreightDto { + @Type(() => Number) @IsNumber() @Min(0.01) weight: number;