import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; 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() export class AdminUsersService { constructor(private readonly prisma: PrismaService) {} async list(query: AdminUsersQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.UserWhereInput = {}; if (query.phone) where.phone = { contains: query.phone }; if (query.userNo) where.userNo = { contains: query.userNo }; if (query.deviceKey) where.deviceKey = query.deviceKey; if (query.status !== undefined) where.status = query.status; if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null }; if (query.phoneVerified === '0') where.phoneVerifiedAt = null; const [items, total] = await Promise.all([ this.prisma.user.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, select: { id: true, userNo: true, deviceKey: true, phone: true, phoneVerifiedAt: true, mergedIntoUserId: true, wxOpenId: true, nickname: true, status: true, createdAt: true, updatedAt: true, _count: { select: { orders: true } }, }, }), this.prisma.user.count({ where }), ]); return serializeBigInt({ items: items.map((u) => mapAdminUserRow(u)), total, page, pageSize, }); } async detail(id: bigint) { const user = await this.prisma.user.findUnique({ where: { id }, include: { cityPreference: true, mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } }, orders: { orderBy: { createdAt: 'desc' }, select: { id: true, orderNo: true, status: true, payAmount: true, payStatus: true, createdAt: true, }, }, _count: { select: { mergedFrom: true, orders: true, addresses: true } }, }, }); if (!user) throw new NotFoundException('用户不存在'); return serializeBigInt({ ...user, wechatVerified: !!user.wxOpenId, mergedFromCount: user._count.mergedFrom, orderCount: user._count.orders, addressCount: user._count.addresses, _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 } }); } }