diff --git a/src/staff/staff.service.spec.ts b/src/staff/staff.service.spec.ts index 0a8efc7..b3d1a70 100644 --- a/src/staff/staff.service.spec.ts +++ b/src/staff/staff.service.spec.ts @@ -24,11 +24,20 @@ const baseDto = { employStatus: 'on', }; -function makeService(overrides: { findFirst?: ReturnType; create?: ReturnType } = {}) { - const findFirst = overrides.findFirst ?? vi.fn().mockResolvedValue(null); +function makeService( + overrides: { + staffFindFirst?: ReturnType; + userFindFirst?: ReturnType; + create?: ReturnType; + } = {}, +) { + const staffFindFirst = overrides.staffFindFirst ?? vi.fn().mockResolvedValue(null); + const userFindFirst = overrides.userFindFirst ?? vi.fn().mockResolvedValue({ id: 'u9', tenantCode: 'T001' }); const create = overrides.create ?? vi.fn().mockResolvedValue({ id: 's1', ...baseDto, tenantCode: 'T001' }); - const prisma = { client: { staff: { findFirst, create } } }; - return { service: new StaffService(prisma as unknown as PrismaService), findFirst, create }; + const prisma = { + client: { staff: { findFirst: staffFindFirst, create }, user: { findFirst: userFindFirst } }, + }; + return { service: new StaffService(prisma as unknown as PrismaService), staffFindFirst, userFindFirst, create }; } describe('StaffService', () => { @@ -42,26 +51,43 @@ describe('StaffService', () => { }); }); - it('userId 已被占用时 create 抛 STAFF_USER_LINKED', async () => { - const findFirst = vi.fn().mockResolvedValue({ id: 's-other', userId: 'u9' }); + it('userId 指向的账号不存在时 create 抛 USER_NOT_FOUND', async () => { + const userFindFirst = vi.fn().mockResolvedValue(null); + const staffFindFirst = vi.fn(); const create = vi.fn(); - const { service } = makeService({ findFirst, create }); + const { service } = makeService({ userFindFirst, staffFindFirst, create }); + + await expect( + runWithRequestContext(ctx, () => service.createStaff({ ...baseDto, userId: 'u9' })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.USER_NOT_FOUND } }); + + expect(userFindFirst).toHaveBeenCalledWith({ where: { id: 'u9', tenantCode: 'T001' } }); + expect(staffFindFirst).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it('userId 已被占用时 create 抛 STAFF_USER_LINKED', async () => { + const staffFindFirst = vi.fn().mockResolvedValue({ id: 's-other', userId: 'u9' }); + const create = vi.fn(); + const { service } = makeService({ staffFindFirst, create }); await expect( runWithRequestContext(ctx, () => service.createStaff({ ...baseDto, userId: 'u9' })), ).rejects.toMatchObject({ response: { code: ErrorCodes.STAFF_USER_LINKED } }); - expect(findFirst).toHaveBeenCalledWith({ where: { userId: 'u9' } }); + expect(staffFindFirst).toHaveBeenCalledWith({ where: { userId: 'u9' } }); expect(create).not.toHaveBeenCalled(); }); it('userId 为空时不校验、直接创建', async () => { - const findFirst = vi.fn(); - const { service, create } = makeService({ findFirst }); + const userFindFirst = vi.fn(); + const staffFindFirst = vi.fn(); + const { service, create } = makeService({ userFindFirst, staffFindFirst }); await runWithRequestContext(ctx, () => service.createStaff(baseDto)); - expect(findFirst).not.toHaveBeenCalled(); + expect(userFindFirst).not.toHaveBeenCalled(); + expect(staffFindFirst).not.toHaveBeenCalled(); expect(create).toHaveBeenCalled(); }); }); diff --git a/src/staff/staff.service.ts b/src/staff/staff.service.ts index d586e84..cb0804f 100644 --- a/src/staff/staff.service.ts +++ b/src/staff/staff.service.ts @@ -1,6 +1,7 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +import { getRequestContext } from '../common/context/request-context.js'; import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; import { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.js'; @@ -25,19 +26,17 @@ export class StaffService extends BaseCrudService { return this.paginate(q.page, q.pageSize, where); } - /** 建档:先校验 userId 未被其他人员占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */ + /** 建档:先校验 userId 指向的账号存在且未被占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */ async createStaff(dto: CreateStaffDto) { - await this.assertUserIdFree(dto.userId); + await this.assertUserIdValid(dto.userId); // TODO(id_card_no 加密): 本期直接存明文,加密机制由后续安全 change 统一落地后再在此处加解密。 return super.create({ ...dto }); } - /** 改人员:改绑 userId 时同样校验唯一;不存在抛 STAFF_NOT_FOUND。 */ + /** 改人员:改绑 userId 时同样校验账号存在与唯一;人员不存在抛 STAFF_NOT_FOUND。 */ async updateStaff(id: string, dto: UpdateStaffDto) { await this.requireExists(id); - if (dto.userId !== undefined) { - await this.assertUserIdFree(dto.userId, id); - } + await this.assertUserIdValid(dto.userId, id); return super.update(id, { ...dto }); } @@ -53,9 +52,26 @@ export class StaffService extends BaseCrudService { return super.update(id, { groupLeaderId: dto.groupLeaderId }); } - /** userId 为 1:1 关联(可空):非空时校验是否已被其他 staff 占用。 */ - private async assertUserIdFree(userId: string | undefined, excludeId?: string) { + /** userId 为 1:1 关联(可空):非空时校验账号存在 + 未被其他 staff 占用。 */ + private async assertUserIdValid(userId: string | undefined, excludeId?: string) { if (!userId) return; + await this.assertUserExists(userId); + await this.assertUserIdFree(userId, excludeId); + } + + /** 校验 userId 指向的账号在本租户内存在(staff.user_id 无 DB 外键,须业务层兜底)。 */ + private async assertUserExists(userId: string) { + const ctx = getRequestContext(); + const user = await this.prisma.client.user.findFirst({ + where: { id: userId, tenantCode: ctx?.tenantCode }, + }); + if (!user) { + throw new BusinessException(ErrorCodes.USER_NOT_FOUND, '账号不存在', HttpStatus.NOT_FOUND); + } + } + + /** 校验 userId 是否已被其他 staff 占用(唯一关联)。 */ + private async assertUserIdFree(userId: string, excludeId?: string) { const existing = await this.db().findFirst({ where: { userId } }); if (existing && existing.id !== excludeId) { throw new BusinessException(ErrorCodes.STAFF_USER_LINKED, '该账号已关联其他人员', HttpStatus.CONFLICT);