fix(c0): staff 绑定 userId 前校验账号在本租户存在
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5a3beb4923
commit
0be521bd72
@ -24,11 +24,20 @@ const baseDto = {
|
|||||||
employStatus: 'on',
|
employStatus: 'on',
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeService(overrides: { findFirst?: ReturnType<typeof vi.fn>; create?: ReturnType<typeof vi.fn> } = {}) {
|
function makeService(
|
||||||
const findFirst = overrides.findFirst ?? vi.fn().mockResolvedValue(null);
|
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 create = overrides.create ?? vi.fn().mockResolvedValue({ id: 's1', ...baseDto, tenantCode: 'T001' });
|
||||||
const prisma = { client: { staff: { findFirst, create } } };
|
const prisma = {
|
||||||
return { service: new StaffService(prisma as unknown as PrismaService), findFirst, create };
|
client: { staff: { findFirst: staffFindFirst, create }, user: { findFirst: userFindFirst } },
|
||||||
|
};
|
||||||
|
return { service: new StaffService(prisma as unknown as PrismaService), staffFindFirst, userFindFirst, create };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('StaffService', () => {
|
describe('StaffService', () => {
|
||||||
@ -42,26 +51,43 @@ describe('StaffService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('userId 已被占用时 create 抛 STAFF_USER_LINKED', async () => {
|
it('userId 指向的账号不存在时 create 抛 USER_NOT_FOUND', async () => {
|
||||||
const findFirst = vi.fn().mockResolvedValue({ id: 's-other', userId: 'u9' });
|
const userFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const staffFindFirst = vi.fn();
|
||||||
const create = 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(
|
await expect(
|
||||||
runWithRequestContext(ctx, () => service.createStaff({ ...baseDto, userId: 'u9' })),
|
runWithRequestContext(ctx, () => service.createStaff({ ...baseDto, userId: 'u9' })),
|
||||||
).rejects.toMatchObject({ response: { code: ErrorCodes.STAFF_USER_LINKED } });
|
).rejects.toMatchObject({ response: { code: ErrorCodes.STAFF_USER_LINKED } });
|
||||||
|
|
||||||
expect(findFirst).toHaveBeenCalledWith({ where: { userId: 'u9' } });
|
expect(staffFindFirst).toHaveBeenCalledWith({ where: { userId: 'u9' } });
|
||||||
expect(create).not.toHaveBeenCalled();
|
expect(create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('userId 为空时不校验、直接创建', async () => {
|
it('userId 为空时不校验、直接创建', async () => {
|
||||||
const findFirst = vi.fn();
|
const userFindFirst = vi.fn();
|
||||||
const { service, create } = makeService({ findFirst });
|
const staffFindFirst = vi.fn();
|
||||||
|
const { service, create } = makeService({ userFindFirst, staffFindFirst });
|
||||||
|
|
||||||
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
|
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
|
||||||
|
|
||||||
expect(findFirst).not.toHaveBeenCalled();
|
expect(userFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(staffFindFirst).not.toHaveBeenCalled();
|
||||||
expect(create).toHaveBeenCalled();
|
expect(create).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||||
import { PrismaService } from '../prisma/prisma.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 { BusinessException } from '../common/business-exception.js';
|
||||||
import { ErrorCodes } from '../common/error-codes.js';
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
import { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.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);
|
return this.paginate(q.page, q.pageSize, where);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 建档:先校验 userId 未被其他人员占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */
|
/** 建档:先校验 userId 指向的账号存在且未被占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */
|
||||||
async createStaff(dto: CreateStaffDto) {
|
async createStaff(dto: CreateStaffDto) {
|
||||||
await this.assertUserIdFree(dto.userId);
|
await this.assertUserIdValid(dto.userId);
|
||||||
// TODO(id_card_no 加密): 本期直接存明文,加密机制由后续安全 change 统一落地后再在此处加解密。
|
// TODO(id_card_no 加密): 本期直接存明文,加密机制由后续安全 change 统一落地后再在此处加解密。
|
||||||
return super.create({ ...dto });
|
return super.create({ ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 改人员:改绑 userId 时同样校验唯一;不存在抛 STAFF_NOT_FOUND。 */
|
/** 改人员:改绑 userId 时同样校验账号存在与唯一;人员不存在抛 STAFF_NOT_FOUND。 */
|
||||||
async updateStaff(id: string, dto: UpdateStaffDto) {
|
async updateStaff(id: string, dto: UpdateStaffDto) {
|
||||||
await this.requireExists(id);
|
await this.requireExists(id);
|
||||||
if (dto.userId !== undefined) {
|
await this.assertUserIdValid(dto.userId, id);
|
||||||
await this.assertUserIdFree(dto.userId, id);
|
|
||||||
}
|
|
||||||
return super.update(id, { ...dto });
|
return super.update(id, { ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,9 +52,26 @@ export class StaffService extends BaseCrudService {
|
|||||||
return super.update(id, { groupLeaderId: dto.groupLeaderId });
|
return super.update(id, { groupLeaderId: dto.groupLeaderId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** userId 为 1:1 关联(可空):非空时校验是否已被其他 staff 占用。 */
|
/** userId 为 1:1 关联(可空):非空时校验账号存在 + 未被其他 staff 占用。 */
|
||||||
private async assertUserIdFree(userId: string | undefined, excludeId?: string) {
|
private async assertUserIdValid(userId: string | undefined, excludeId?: string) {
|
||||||
if (!userId) return;
|
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 } });
|
const existing = await this.db().findFirst({ where: { userId } });
|
||||||
if (existing && existing.id !== excludeId) {
|
if (existing && existing.id !== excludeId) {
|
||||||
throw new BusinessException(ErrorCodes.STAFF_USER_LINKED, '该账号已关联其他人员', HttpStatus.CONFLICT);
|
throw new BusinessException(ErrorCodes.STAFF_USER_LINKED, '该账号已关联其他人员', HttpStatus.CONFLICT);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user