fix(c0): staff 绑定 userId 前校验账号在本租户存在

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 13:01:32 +08:00
parent 5a3beb4923
commit 0be521bd72
2 changed files with 61 additions and 19 deletions

View File

@ -24,11 +24,20 @@ const baseDto = {
employStatus: 'on',
};
function makeService(overrides: { findFirst?: ReturnType<typeof vi.fn>; create?: ReturnType<typeof vi.fn> } = {}) {
const findFirst = overrides.findFirst ?? vi.fn().mockResolvedValue(null);
function makeService(
overrides: {
staffFindFirst?: ReturnType<typeof vi.fn>;
userFindFirst?: ReturnType<typeof vi.fn>;
create?: ReturnType<typeof vi.fn>;
} = {},
) {
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();
});
});

View File

@ -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);