import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import type { HqAdminRoleValue } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto'; import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto'; import { hashPassword } from '../../common/crypto/password.util'; const HQ_ACCOUNT_SELECT = { id: true, phone: true, loginName: true, passwordHash: true, name: true, adminRole: true, status: true, lastLoginAt: true, createdAt: true, cities: { select: { cityId: true } }, } satisfies Prisma.HqAccountSelect; function mapHqAccountRow(account: { id: bigint; phone: string; loginName: string | null; passwordHash: string | null; name: string; adminRole: string; status: string; lastLoginAt: Date | null; createdAt: Date; cities?: { cityId: bigint }[]; }) { return { id: account.id, phone: account.phone, loginName: account.loginName, hasPassword: !!account.passwordHash, name: account.name, adminRole: account.adminRole, status: account.status, lastLoginAt: account.lastLoginAt, createdAt: account.createdAt, cityIds: (account.cities ?? []).map((c) => c.cityId.toString()), }; } @Injectable() export class AdminHqAccountsService { constructor(private readonly prisma: PrismaService) {} private async assertCityIds(cityIds: string[]) { const unique = [...new Set(cityIds.map((id) => id.trim()).filter(Boolean))]; if (!unique.length) return [] as bigint[]; const ids = unique.map((id) => BigInt(id)); const count = await this.prisma.commonCity.count({ where: { id: { in: ids } } }); if (count !== ids.length) { throw new BadRequestException('存在无效城市'); } return ids; } private assertCityRequirement(adminRole: string, cityIds: bigint[]) { if (adminRole === 'CITY_STORE_SERVICE' && !cityIds.length) { throw new BadRequestException('城市门店服务须至少勾选一个负责城市'); } } private async replaceCities(tx: Prisma.TransactionClient, hqAccountId: bigint, cityIds: bigint[]) { await tx.hqAccountCity.deleteMany({ where: { hqAccountId } }); if (!cityIds.length) return; await tx.hqAccountCity.createMany({ data: cityIds.map((cityId) => ({ hqAccountId, cityId })), }); } async list(query: AdminHqAccountsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.HqAccountWhereInput = {}; if (query.phone) where.phone = { contains: query.phone }; if (query.adminRole) where.adminRole = query.adminRole as Prisma.EnumHqAdminRoleFilter['equals']; if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; const [items, total] = await Promise.all([ this.prisma.hqAccount.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, select: HQ_ACCOUNT_SELECT, }), this.prisma.hqAccount.count({ where }), ]); return serializeBigInt({ items: items.map(mapHqAccountRow), total, page, pageSize, }); } async detail(id: bigint) { const account = await this.prisma.hqAccount.findUnique({ where: { id }, select: HQ_ACCOUNT_SELECT, }); if (!account) throw new NotFoundException('HQ 账号不存在'); return serializeBigInt(mapHqAccountRow(account)); } async create(dto: CreateHqAccountDto) { const adminRole = (dto.adminRole ?? 'OPS') as HqAdminRoleValue; const cityIds = await this.assertCityIds(dto.cityIds ?? []); this.assertCityRequirement(adminRole, cityIds); if (dto.credentialType === 'phone') { if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号'); const phone = dto.phone.trim(); const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); if (exists) throw new BadRequestException('手机号已存在'); const account = await this.prisma.$transaction(async (tx) => { const created = await tx.hqAccount.create({ data: { phone, name: dto.name, adminRole }, select: HQ_ACCOUNT_SELECT, }); await this.replaceCities(tx, created.id, cityIds); return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT }); }); return serializeBigInt(mapHqAccountRow(account)); } if (!dto.loginName?.trim() || !dto.password) { throw new BadRequestException('账号密码模式需填写用户名和密码'); } const loginName = dto.loginName.trim(); const loginTaken = await this.prisma.hqAccount.findUnique({ where: { loginName } }); if (loginTaken) throw new BadRequestException('用户名已存在'); const phone = dto.phone?.trim() || (await this.generatePlaceholderPhone()); const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } }); if (phoneTaken) throw new BadRequestException('手机号已存在'); const account = await this.prisma.$transaction(async (tx) => { const created = await tx.hqAccount.create({ data: { phone, loginName, passwordHash: hashPassword(dto.password!), name: dto.name, adminRole, }, select: HQ_ACCOUNT_SELECT, }); await this.replaceCities(tx, created.id, cityIds); return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT }); }); return serializeBigInt(mapHqAccountRow(account)); } async update(id: bigint, dto: UpdateHqAccountDto) { const current = await this.prisma.hqAccount.findUnique({ where: { id }, include: { cities: { select: { cityId: true } } }, }); if (!current) throw new NotFoundException('HQ 账号不存在'); const loginNameInput = dto.loginName === undefined ? undefined : dto.loginName.trim(); if (loginNameInput) { const conflict = await this.prisma.hqAccount.findFirst({ where: { loginName: loginNameInput, id: { not: id } }, }); if (conflict) throw new BadRequestException('用户名已存在'); } if (dto.phone !== undefined) { const phone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { throw new BadRequestException('请输入正确的手机号'); } const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } }); if (phoneTaken && phoneTaken.id !== id) { throw new BadRequestException('手机号已存在'); } } const nextRole = (dto.adminRole ?? current.adminRole) as HqAdminRoleValue; const nextCityIds = dto.cityIds !== undefined ? await this.assertCityIds(dto.cityIds) : current.cities.map((c) => c.cityId); this.assertCityRequirement(nextRole, nextCityIds); const roleChanged = dto.adminRole !== undefined && dto.adminRole !== current.adminRole; const account = await this.prisma.$transaction(async (tx) => { await tx.hqAccount.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}), ...(loginNameInput ? { loginName: loginNameInput } : {}), ...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}), ...(dto.adminRole !== undefined ? { adminRole: nextRole } : {}), ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), }, }); if (dto.cityIds !== undefined) { await this.replaceCities(tx, id, nextCityIds); } if (roleChanged) { await tx.hqAccountPermission.deleteMany({ where: { hqAccountId: id } }); } return tx.hqAccount.findUniqueOrThrow({ where: { id }, select: HQ_ACCOUNT_SELECT }); }); return serializeBigInt(mapHqAccountRow(account)); } private async generatePlaceholderPhone(): Promise { for (let i = 0; i < 8; i += 1) { const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`.slice(-8); const phone = `199${suffix}`; const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); if (!exists) return phone; } throw new BadRequestException('无法生成占位手机号,请手动填写'); } }