88053353ab
- admin 用户列表新增剩余/已用/累计好客权益金额三列(benefitCoupon groupBy 聚合,排除 VOID) - admin/partner 门店入驻合同支持多张照片与 PDF(CommonResource 多记录,复用 ENV 多图逻辑) - 新增 contract-urls.util 归一化工具,向后兼容旧 contractUrl 字段并标记 deprecated 需求1/2/3
347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
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<Map<string, UserBenefitStat>> {
|
|
const map = new Map<string, UserBenefitStat>();
|
|
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 } });
|
|
}
|
|
}
|