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; /** 权益统计口径:VOID(退款作废)不计入,与 C 端 listCoupons 一致 */ const BENEFIT_STAT_STATUSES = ['ACTIVE', 'USED_UP'] as const; export type UserBenefitStat = { /** 累计获得(含已使用) */ benefitTotalAmount: number; /** 已使用(已核销) */ benefitUsedAmount: number; /** 剩余未使用(可核销余额) */ benefitBalance: number; }; const EMPTY_BENEFIT_STAT: UserBenefitStat = { benefitTotalAmount: 0, benefitUsedAmount: 0, benefitBalance: 0, }; function toAmount(v: Prisma.Decimal | number | null | undefined) { return v == null ? 0 : Number(v); } 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; sourceType: string; sourceRefId: bigint | null; sourceLabel: string | null; isTest?: boolean; createdAt: Date; updatedAt: Date; _count: { orders: number }; }, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) { 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, sourceType: u.sourceType, sourceRefId: u.sourceRefId, sourceLabel: u.sourceLabel, isTest: !!u.isTest, createdAt: u.createdAt, updatedAt: u.updatedAt, orderCount: u._count.orders, ...benefit, }; } @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; if (query.excludeTest) where.isTest = false; 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, sourceType: true, sourceRefId: true, sourceLabel: true, isTest: true, createdAt: true, updatedAt: true, _count: { select: { orders: true } }, }, }), this.prisma.user.count({ where }), ]); const benefitMap = await this.loadBenefitStats(items.map((u) => u.id)); return serializeBigInt({ items: items.map((u) => mapAdminUserRow(u, benefitMap.get(u.id.toString()))), total, page, pageSize, }); } /** 批量聚合当前页用户的好客权益金额(累计获得 / 已使用 / 剩余) */ private async loadBenefitStats(userIds: bigint[]): Promise> { const map = new Map(); if (!userIds.length) return map; const grouped = await this.prisma.benefitCoupon.groupBy({ by: ['userId'], where: { userId: { in: userIds }, status: { in: [...BENEFIT_STAT_STATUSES] } }, _sum: { totalAmount: true, usedAmount: true, balance: true }, }); for (const row of grouped) { map.set(row.userId.toString(), { benefitTotalAmount: toAmount(row._sum.totalAmount), benefitUsedAmount: toAmount(row._sum.usedAmount), benefitBalance: toAmount(row._sum.balance), }); } return map; } 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('用户不存在'); let sourcePromo: { id: bigint; code: string; name: string } | null = null; if (user.sourceType === 'PROMO_CODE' && user.sourceRefId) { sourcePromo = await this.prisma.commonPromoCode.findUnique({ where: { id: user.sourceRefId }, select: { id: true, code: true, name: true }, }); } const benefitMap = await this.loadBenefitStats([user.id]); return serializeBigInt({ ...user, ...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT), wechatVerified: !!user.wxOpenId, mergedFromCount: user._count.mergedFrom, orderCount: user._count.orders, addressCount: user._count.addresses, sourcePromo, _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 } }); } }