import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import { AnalyticsService } from '../analytics/analytics.service'; import { AuthService } from './auth.service'; import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto'; @Injectable() export class PartnerStaffService { constructor( private readonly prisma: PrismaService, private readonly analytics: AnalyticsService, private readonly authService: AuthService, ) {} async listStaff(parentAccountId: bigint) { const rows = await this.prisma.partnerAccount.findMany({ where: { parentAccountId }, orderBy: { createdAt: 'desc' }, }); return rows.map((row) => this.toStaffItem(row)); } async sendStaffPhoneSms(actor: AuthUser, phone: string) { const parentAccountId = actor.actorId; const parent = await this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: parentAccountId }, }); if (parent.isPrimary !== 1) { throw new BadRequestException('仅主账号可添加子账号'); } const normalized = phone.trim(); if (!/^1[3-9]\d{9}$/.test(normalized)) { throw new BadRequestException('请输入正确的手机号码'); } const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } }); if (existing) throw new BadRequestException('该手机号已被使用'); const masked = this.maskPhone(normalized); try { await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, { clientApp: ClientApp.PARTNER_H5, }); this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, { phone: masked, scene: SmsScene.PARTNER_STAFF_ADD, status: 'success', }); } catch (err) { if (err instanceof BadRequestException) { this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, { phone: masked, scene: SmsScene.PARTNER_STAFF_ADD, status: 'failed', reason: err.message, }); } throw err; } return { ok: true, maskedPhone: masked }; } async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) { const parentAccountId = actor.actorId; const parent = await this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: parentAccountId }, }); if (parent.isPrimary !== 1) { throw new BadRequestException('仅主账号可添加子账号'); } const phone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { throw new BadRequestException('请输入正确的手机号码'); } const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } }); if (existing) throw new BadRequestException('该手机号已被使用'); const smsCode = dto.smsCode.trim(); if (!smsCode) throw new BadRequestException('请输入手机号验证码'); try { await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD); } catch (err) { this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, { phone: this.maskPhone(phone), reason: err instanceof BadRequestException ? err.message : '验证码错误', }); throw err; } const name = dto.name.trim(); if (!name) throw new BadRequestException('请填写真实姓名'); const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL; const permissions = dto.permissions && dto.permissions.length > 0 ? dto.permissions : [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS]; const account = await this.prisma.partnerAccount.create({ data: { phone, name, staffRole, permissions, isPrimary: 0, parentAccountId: parent.id, status: 'DISABLED', }, }); this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, { name, phone: this.maskPhone(phone), staffRole, permissions, status: account.status, phoneVerified: true, }); return this.toStaffItem(account); } async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) { const parentAccountId = actor.actorId; const staff = await this.assertStaffOwned(parentAccountId, staffId); const before = { name: staff.name, staffRole: staff.staffRole, status: staff.status, }; const data: Record = {}; if (dto.name !== undefined) { const name = dto.name.trim(); if (!name) throw new BadRequestException('请填写真实姓名'); data.name = name; } if (dto.staffRole !== undefined) { data.staffRole = dto.staffRole as PartnerStaffRole; } if (dto.permissions !== undefined) { data.permissions = dto.permissions; } if (dto.status !== undefined) { data.status = dto.status; } const updated = await this.prisma.partnerAccount.update({ where: { id: staff.id }, data, }); const onlyRoleChange = (dto.staffRole !== undefined || dto.permissions !== undefined) && dto.name === undefined && dto.status === undefined; const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update'; const primaryId = parentAccountId; this.trackStaffEvent(actor, primaryId, eventName, staff.id, { before, after: { name: updated.name, staffRole: updated.staffRole, status: updated.status, }, }); return this.toStaffItem(updated); } async deleteStaff(actor: AuthUser, staffId: bigint) { const parentAccountId = actor.actorId; const staff = await this.assertStaffOwned(parentAccountId, staffId); this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, { name: staff.name, phone: this.maskPhone(staff.phone), staffRole: staff.staffRole, status: staff.status, }); await this.prisma.partnerAccount.delete({ where: { id: staff.id } }); return { ok: true }; } private trackStaffEvent( actor: AuthUser, primaryAccountId: bigint, eventName: string, refId: bigint, extraJson?: Record, ) { this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, { partnerAccountId: primaryAccountId, eventName, refType: 'PARTNER_ACCOUNT', refId, extraJson, }); } private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) { const staff = await this.prisma.partnerAccount.findFirst({ where: { id: staffId, parentAccountId }, }); if (!staff) throw new NotFoundException('子账号不存在'); return staff; } private toStaffItem(row: { id: bigint; name: string; phone: string; staffRole: string | null; permissions?: unknown; status: string; lastLoginAt: Date | null; }) { return serializeBigInt({ id: row.id.toString(), name: row.name, phone: this.maskPhone(row.phone), staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL, permissions: Array.isArray(row.permissions) ? row.permissions : undefined, status: row.status, lastLoginAt: row.lastLoginAt?.toISOString(), }); } private maskPhone(phone: string): string { if (phone.length !== 11) return phone; return `${phone.slice(0, 3)} **** ${phone.slice(7)}`; } }