feat(security): id_card_no 存储层 AES-256-GCM 加密 + SHA-256 哈希唯一
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29fc5e9432
commit
394d7a9731
@ -17,6 +17,9 @@ JWT_SECRET="dev-secret-change-me"
|
|||||||
JWT_ACCESS_TTL="7200"
|
JWT_ACCESS_TTL="7200"
|
||||||
JWT_REFRESH_TTL="604800"
|
JWT_REFRESH_TTL="604800"
|
||||||
|
|
||||||
|
# 身份证号存储层加密密钥(64 hex = 32 字节);生产务必替换为随机值
|
||||||
|
DATA_ENC_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
# 日志(详见 src/logging/logger.config.ts)
|
# 日志(详见 src/logging/logger.config.ts)
|
||||||
LOG_LEVEL="info"
|
LOG_LEVEL="info"
|
||||||
LOG_PRETTY="true"
|
LOG_PRETTY="true"
|
||||||
|
|||||||
48
prisma/encrypt-idcard.ts
Normal file
48
prisma/encrypt-idcard.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { encrypt, hash } from '../src/common/id-card-cipher.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* id_card_no 加密回填脚本(迁移 A 之后、迁移 B 之前运行一次)。
|
||||||
|
*
|
||||||
|
* 遍历 ltc_elder / ltc_staff,读取明文 `id_card_no`,计算
|
||||||
|
* `id_card_no_hash = SHA-256(明文)`、`id_card_no_enc = AES-256-GCM(明文)`,回写两列。
|
||||||
|
* 幂等:可重复执行(按主键逐行覆盖)。
|
||||||
|
*
|
||||||
|
* 运行:pnpm exec tsx prisma/encrypt-idcard.ts
|
||||||
|
*/
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function backfill(table: 'ltc_elder' | 'ltc_staff'): Promise<number> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Array<{ id: string; id_card_no: string | null }>>(
|
||||||
|
`SELECT id, id_card_no FROM ${table} WHERE id_card_no IS NOT NULL AND id_card_no <> ''`,
|
||||||
|
);
|
||||||
|
let updated = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const plain = row.id_card_no as string;
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE ${table} SET id_card_no_hash = ?, id_card_no_enc = ? WHERE id = ?`,
|
||||||
|
hash(plain),
|
||||||
|
encrypt(plain),
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
console.log(`${table}: 回填 ${updated} 行`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const elderCount = await backfill('ltc_elder');
|
||||||
|
const staffCount = await backfill('ltc_staff');
|
||||||
|
console.log(`回填完成:elder=${elderCount} staff=${staffCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- 迁移 A(id_card_no 存储层加密,阶段 1/3):为 ltc_elder / ltc_staff 增加密文与哈希列(先可空,回填脚本跑完后再收紧)。
|
||||||
|
-- 列 collation 继承表级 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci。
|
||||||
|
ALTER TABLE `ltc_elder` ADD COLUMN `id_card_no_enc` VARCHAR(512) NULL, ADD COLUMN `id_card_no_hash` CHAR(64) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ltc_staff` ADD COLUMN `id_card_no_enc` VARCHAR(512) NULL, ADD COLUMN `id_card_no_hash` CHAR(64) NULL;
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- 迁移 B(id_card_no 存储层加密,阶段 3/3):删除明文列,密文/哈希列收紧为 NOT NULL,
|
||||||
|
-- 删除旧唯一索引 (tenant_code, id_card_no),新增 (tenant_code, id_card_no_hash) 唯一索引。
|
||||||
|
|
||||||
|
ALTER TABLE `ltc_elder` DROP INDEX `ltc_elder_tenant_code_id_card_no_key`;
|
||||||
|
ALTER TABLE `ltc_elder` DROP COLUMN `id_card_no`;
|
||||||
|
ALTER TABLE `ltc_elder` MODIFY COLUMN `id_card_no_enc` VARCHAR(512) NOT NULL, MODIFY COLUMN `id_card_no_hash` CHAR(64) NOT NULL;
|
||||||
|
ALTER TABLE `ltc_elder` ADD UNIQUE INDEX `ltc_elder_tenant_code_id_card_no_hash_key`(`tenant_code`, `id_card_no_hash`);
|
||||||
|
|
||||||
|
ALTER TABLE `ltc_staff` DROP COLUMN `id_card_no`;
|
||||||
|
ALTER TABLE `ltc_staff` MODIFY COLUMN `id_card_no_enc` VARCHAR(512) NOT NULL, MODIFY COLUMN `id_card_no_hash` CHAR(64) NOT NULL;
|
||||||
@ -331,7 +331,8 @@ model Staff {
|
|||||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
userId String? @unique @map("user_id") @db.VarChar(36)
|
userId String? @unique @map("user_id") @db.VarChar(36)
|
||||||
name String @db.VarChar(30)
|
name String @db.VarChar(30)
|
||||||
idCardNo String @map("id_card_no") @db.VarChar(255)
|
idCardNoEnc String @map("id_card_no_enc") @db.VarChar(512)
|
||||||
|
idCardNoHash String @map("id_card_no_hash") @db.Char(64)
|
||||||
personCode String? @map("person_code") @db.VarChar(20)
|
personCode String? @map("person_code") @db.VarChar(20)
|
||||||
phone String @db.VarChar(15)
|
phone String @db.VarChar(15)
|
||||||
jobType String @map("job_type") @db.VarChar(20)
|
jobType String @map("job_type") @db.VarChar(20)
|
||||||
@ -440,7 +441,8 @@ model Elder {
|
|||||||
name String @db.VarChar(30)
|
name String @db.VarChar(30)
|
||||||
gender String @db.Char(1)
|
gender String @db.Char(1)
|
||||||
birthDate DateTime @map("birth_date") @db.Date
|
birthDate DateTime @map("birth_date") @db.Date
|
||||||
idCardNo String @map("id_card_no") @db.VarChar(255)
|
idCardNoEnc String @map("id_card_no_enc") @db.VarChar(512)
|
||||||
|
idCardNoHash String @map("id_card_no_hash") @db.Char(64)
|
||||||
phone String? @db.VarChar(15)
|
phone String? @db.VarChar(15)
|
||||||
address String @db.VarChar(200)
|
address String @db.VarChar(200)
|
||||||
guardianName String? @map("guardian_name") @db.VarChar(30)
|
guardianName String? @map("guardian_name") @db.VarChar(30)
|
||||||
@ -471,7 +473,7 @@ model Elder {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
@@unique([tenantCode, idCardNo])
|
@@unique([tenantCode, idCardNoHash])
|
||||||
@@index([tenantCode, name])
|
@@index([tenantCode, name])
|
||||||
@@map("ltc_elder")
|
@@map("ltc_elder")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { TENANT_ADMIN_ROLE_CODE } from '../src/common/constants.js';
|
import { TENANT_ADMIN_ROLE_CODE } from '../src/common/constants.js';
|
||||||
import { initTenant } from '../src/common/tenant-template.js';
|
import { initTenant } from '../src/common/tenant-template.js';
|
||||||
|
import { encrypt, hash } from '../src/common/id-card-cipher.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试机构(TY20261015 投云养老服务机构)演示数据 seed。
|
* 测试机构(TY20261015 投云养老服务机构)演示数据 seed。
|
||||||
@ -174,12 +175,14 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// 3. staff:1 组长(刘组长,绑 leader01)+ 3 护理员(张/李/王,张护理绑 nurse01)
|
// 3. staff:1 组长(刘组长,绑 leader01)+ 3 护理员(张/李/王,张护理绑 nurse01)
|
||||||
const leaderId = IDS.staffLeader;
|
const leaderId = IDS.staffLeader;
|
||||||
|
const leaderIdCard = idCard('53010219850101004');
|
||||||
await prisma.staff.upsert({
|
await prisma.staff.upsert({
|
||||||
where: { id: leaderId },
|
where: { id: leaderId },
|
||||||
update: {
|
update: {
|
||||||
userId: userIds.leader01,
|
userId: userIds.leader01,
|
||||||
name: '刘组长',
|
name: '刘组长',
|
||||||
idCardNo: idCard('53010219850101004'),
|
idCardNoHash: hash(leaderIdCard),
|
||||||
|
idCardNoEnc: encrypt(leaderIdCard),
|
||||||
jobType: 'leader',
|
jobType: 'leader',
|
||||||
workType: 'fulltime',
|
workType: 'fulltime',
|
||||||
employStatus: 'on_job',
|
employStatus: 'on_job',
|
||||||
@ -190,7 +193,8 @@ async function main(): Promise<void> {
|
|||||||
tenantCode: TENANT_CODE,
|
tenantCode: TENANT_CODE,
|
||||||
userId: userIds.leader01,
|
userId: userIds.leader01,
|
||||||
name: '刘组长',
|
name: '刘组长',
|
||||||
idCardNo: idCard('53010219850101004'),
|
idCardNoHash: hash(leaderIdCard),
|
||||||
|
idCardNoEnc: encrypt(leaderIdCard),
|
||||||
phone: '13800000004',
|
phone: '13800000004',
|
||||||
jobType: 'leader',
|
jobType: 'leader',
|
||||||
workType: 'fulltime',
|
workType: 'fulltime',
|
||||||
@ -207,12 +211,14 @@ async function main(): Promise<void> {
|
|||||||
];
|
];
|
||||||
const nurseStaffIds: Record<string, string> = {};
|
const nurseStaffIds: Record<string, string> = {};
|
||||||
for (const nd of nurseDefs) {
|
for (const nd of nurseDefs) {
|
||||||
|
const ndIdCard = idCard(nd.idPrefix);
|
||||||
await prisma.staff.upsert({
|
await prisma.staff.upsert({
|
||||||
where: { id: nd.id },
|
where: { id: nd.id },
|
||||||
update: {
|
update: {
|
||||||
userId: nd.userId,
|
userId: nd.userId,
|
||||||
name: nd.name,
|
name: nd.name,
|
||||||
idCardNo: idCard(nd.idPrefix),
|
idCardNoHash: hash(ndIdCard),
|
||||||
|
idCardNoEnc: encrypt(ndIdCard),
|
||||||
jobType: 'nurse',
|
jobType: 'nurse',
|
||||||
workType: 'fulltime',
|
workType: 'fulltime',
|
||||||
employStatus: 'on_job',
|
employStatus: 'on_job',
|
||||||
@ -223,7 +229,8 @@ async function main(): Promise<void> {
|
|||||||
tenantCode: TENANT_CODE,
|
tenantCode: TENANT_CODE,
|
||||||
userId: nd.userId,
|
userId: nd.userId,
|
||||||
name: nd.name,
|
name: nd.name,
|
||||||
idCardNo: idCard(nd.idPrefix),
|
idCardNoHash: hash(ndIdCard),
|
||||||
|
idCardNoEnc: encrypt(ndIdCard),
|
||||||
phone: nd.phone,
|
phone: nd.phone,
|
||||||
jobType: 'nurse',
|
jobType: 'nurse',
|
||||||
workType: 'fulltime',
|
workType: 'fulltime',
|
||||||
@ -277,7 +284,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// 5. 老人:张三(active)、李四(waiting_period,垫付演示)
|
// 5. 老人:张三(active)、李四(waiting_period,垫付演示)
|
||||||
const zhang = await prisma.elder.upsert({
|
const zhang = await prisma.elder.upsert({
|
||||||
where: { tenantCode_idCardNo: { tenantCode: TENANT_CODE, idCardNo: ID_ZHANG } },
|
where: { tenantCode_idCardNoHash: { tenantCode: TENANT_CODE, idCardNoHash: hash(ID_ZHANG) } },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
tenantCode: TENANT_CODE,
|
tenantCode: TENANT_CODE,
|
||||||
@ -285,7 +292,8 @@ async function main(): Promise<void> {
|
|||||||
name: '张三',
|
name: '张三',
|
||||||
gender: 'M',
|
gender: 'M',
|
||||||
birthDate: dateOnly('1990-01-01'),
|
birthDate: dateOnly('1990-01-01'),
|
||||||
idCardNo: ID_ZHANG,
|
idCardNoHash: hash(ID_ZHANG),
|
||||||
|
idCardNoEnc: encrypt(ID_ZHANG),
|
||||||
phone: '13900000001',
|
phone: '13900000001',
|
||||||
address: '昆明市五华区春城路 1 号',
|
address: '昆明市五华区春城路 1 号',
|
||||||
guardianName: '张小明',
|
guardianName: '张小明',
|
||||||
@ -301,7 +309,7 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await prisma.elder.upsert({
|
await prisma.elder.upsert({
|
||||||
where: { tenantCode_idCardNo: { tenantCode: TENANT_CODE, idCardNo: ID_LI } },
|
where: { tenantCode_idCardNoHash: { tenantCode: TENANT_CODE, idCardNoHash: hash(ID_LI) } },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
tenantCode: TENANT_CODE,
|
tenantCode: TENANT_CODE,
|
||||||
@ -309,7 +317,8 @@ async function main(): Promise<void> {
|
|||||||
name: '李四',
|
name: '李四',
|
||||||
gender: 'F',
|
gender: 'F',
|
||||||
birthDate: dateOnly('1990-03-03'),
|
birthDate: dateOnly('1990-03-03'),
|
||||||
idCardNo: ID_LI,
|
idCardNoHash: hash(ID_LI),
|
||||||
|
idCardNoEnc: encrypt(ID_LI),
|
||||||
phone: '13900000003',
|
phone: '13900000003',
|
||||||
address: '昆明市盘龙区东风路 2 号',
|
address: '昆明市盘龙区东风路 2 号',
|
||||||
insurNo: 'YN5301000002',
|
insurNo: 'YN5301000002',
|
||||||
|
|||||||
38
src/common/id-card-cipher.spec.ts
Normal file
38
src/common/id-card-cipher.spec.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { decrypt, decryptIdCard, encrypt, hash } from './id-card-cipher.js';
|
||||||
|
|
||||||
|
describe('id-card-cipher', () => {
|
||||||
|
it('加密后可解密回原文(roundtrip)', () => {
|
||||||
|
const plain = '530102199001011231';
|
||||||
|
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同一明文两次加密产生不同密文(随机 IV)', () => {
|
||||||
|
const plain = '530102199001011231';
|
||||||
|
expect(encrypt(plain)).not.toBe(encrypt(plain));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('密文为 base64(iv || authTag || ciphertext),长度稳定且不含明文', () => {
|
||||||
|
const plain = '530102199001011231';
|
||||||
|
const enc = encrypt(plain);
|
||||||
|
const buf = Buffer.from(enc, 'base64');
|
||||||
|
// 12 IV + 16 authTag + 18 ciphertext(AES-GCM 不增补长度)
|
||||||
|
expect(buf.length).toBe(12 + 16 + 18);
|
||||||
|
expect(enc).not.toContain(plain);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hash 输出 64 位 hex 且稳定', () => {
|
||||||
|
const plain = '530102199001011231';
|
||||||
|
const h = hash(plain);
|
||||||
|
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(hash(plain)).toBe(h);
|
||||||
|
expect(hash('530102199001011232')).not.toBe(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decryptIdCard 空值/非法值返回空串而不抛错', () => {
|
||||||
|
expect(decryptIdCard('')).toBe('');
|
||||||
|
expect(decryptIdCard(null)).toBe('');
|
||||||
|
expect(decryptIdCard(undefined)).toBe('');
|
||||||
|
expect(decryptIdCard('not-base64!!!')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
61
src/common/id-card-cipher.ts
Normal file
61
src/common/id-card-cipher.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 身份证号存储层加解密/哈希工具。
|
||||||
|
*
|
||||||
|
* - 加密:AES-256-GCM,随机 12 字节 IV + 16 字节 authTag;
|
||||||
|
* 存储格式 `id_card_no_enc VARCHAR(512)` = base64(iv || authTag || ciphertext);
|
||||||
|
* - 哈希:SHA-256(明文) hex(64 字符),用于 `id_card_no_hash CHAR(64)` 唯一索引与等值查找。
|
||||||
|
*
|
||||||
|
* 密钥:`process.env.DATA_ENC_KEY`(64 hex = 32 字节)。缺省或非法时回退到下方开发 key
|
||||||
|
* (仅本地/测试用;生产必须通过环境变量配置随机 64 hex,切勿使用回退 key)。
|
||||||
|
*/
|
||||||
|
const ALGO = 'aes-256-gcm';
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const AUTH_TAG_LEN = 16;
|
||||||
|
|
||||||
|
// 开发回退密钥(生产必须配置 DATA_ENC_KEY,禁止使用此值)
|
||||||
|
const DEV_FALLBACK_KEY_HEX = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||||
|
|
||||||
|
function key(): Buffer {
|
||||||
|
const hex = process.env.DATA_ENC_KEY?.trim() || DEV_FALLBACK_KEY_HEX;
|
||||||
|
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
||||||
|
throw new Error('DATA_ENC_KEY 必须是 64 位 hex(32 字节)');
|
||||||
|
}
|
||||||
|
return Buffer.from(hex, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加密:返回 base64(iv || authTag || ciphertext)。 */
|
||||||
|
export function encrypt(plain: string): string {
|
||||||
|
const iv = randomBytes(IV_LEN);
|
||||||
|
const cipher = createCipheriv(ALGO, key(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解密:入参为 encrypt() 的返回值;密钥/密文不匹配时抛错。 */
|
||||||
|
export function decrypt(enc: string): string {
|
||||||
|
const buf = Buffer.from(enc, 'base64');
|
||||||
|
const iv = buf.subarray(0, IV_LEN);
|
||||||
|
const authTag = buf.subarray(IV_LEN, IV_LEN + AUTH_TAG_LEN);
|
||||||
|
const encrypted = buf.subarray(IV_LEN + AUTH_TAG_LEN);
|
||||||
|
const decipher = createDecipheriv(ALGO, key(), iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256(明文) hex(64 字符)。 */
|
||||||
|
export function hash(plain: string): string {
|
||||||
|
return createHash('sha256').update(plain, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解密后安全取值:空值或解密失败一律返回空串,避免展示链路因密钥变更而崩溃。 */
|
||||||
|
export function decryptIdCard(enc: string | null | undefined): string {
|
||||||
|
if (!enc) return '';
|
||||||
|
try {
|
||||||
|
return decrypt(enc);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { DocumentService, EXPORT_NOTICE } from './document.service.js';
|
import { DocumentService, EXPORT_NOTICE } from './document.service.js';
|
||||||
import type { PrismaService } from '../prisma/prisma.service.js';
|
import type { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { ErrorCodes } from '../common/error-codes.js';
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
import { encrypt } from '../common/id-card-cipher.js';
|
||||||
import { runWithRequestContext } from '../common/context/request-context.js';
|
import { runWithRequestContext } from '../common/context/request-context.js';
|
||||||
import type { RequestContext } from '../common/context/request-context.js';
|
import type { RequestContext } from '../common/context/request-context.js';
|
||||||
|
|
||||||
@ -75,7 +76,7 @@ describe('DocumentService.export', () => {
|
|||||||
name: '张三',
|
name: '张三',
|
||||||
gender: '1',
|
gender: '1',
|
||||||
birthDate: new Date('1950-01-01T00:00:00Z'),
|
birthDate: new Date('1950-01-01T00:00:00Z'),
|
||||||
idCardNo: '530102195001010011',
|
idCardNoEnc: encrypt('530102195001010011'),
|
||||||
phone: '13800000000',
|
phone: '13800000000',
|
||||||
address: '昆明市',
|
address: '昆明市',
|
||||||
insurNo: 'I001',
|
insurNo: 'I001',
|
||||||
@ -206,7 +207,7 @@ describe('DocumentService.export', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('无日期范围时不加 checkinTime 条件', async () => {
|
it('无日期范围时不加 checkinTime 条件', async () => {
|
||||||
const elder = { id: 'e1', name: '张三', gender: '1', birthDate: new Date('1950-01-01T00:00:00Z'), idCardNo: '530102195001010011', phone: null, address: '昆明市', insurNo: 'I001', careLevel: '重度', treatStatus: 'active', benefitStart: null, benefitEnd: null };
|
const elder = { id: 'e1', name: '张三', gender: '1', birthDate: new Date('1950-01-01T00:00:00Z'), idCardNoEnc: encrypt('530102195001010011'), phone: null, address: '昆明市', insurNo: 'I001', careLevel: '重度', treatStatus: 'active', benefitStart: null, benefitEnd: null };
|
||||||
const recordFindMany = vi.fn().mockResolvedValue([]);
|
const recordFindMany = vi.fn().mockResolvedValue([]);
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
elderFindUnique: vi.fn().mockResolvedValue(elder),
|
elderFindUnique: vi.fn().mockResolvedValue(elder),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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 { maskIdCard } from '../elder/elder.service.js';
|
import { maskIdCard } from '../elder/elder.service.js';
|
||||||
|
import { decryptIdCard } from '../common/id-card-cipher.js';
|
||||||
|
|
||||||
/** 导出水印固定文案(R4:只用「日常留痕,随时可导出」,禁用迎检/应对巡查等词)。 */
|
/** 导出水印固定文案(R4:只用「日常留痕,随时可导出」,禁用迎检/应对巡查等词)。 */
|
||||||
export const EXPORT_NOTICE = '日常留痕,随时可导出';
|
export const EXPORT_NOTICE = '日常留痕,随时可导出';
|
||||||
@ -101,7 +102,7 @@ export class DocumentService {
|
|||||||
name: elder.name,
|
name: elder.name,
|
||||||
gender: elder.gender,
|
gender: elder.gender,
|
||||||
birthDate: fmtDate(elder.birthDate),
|
birthDate: fmtDate(elder.birthDate),
|
||||||
idCardNo: maskIdCard(String(elder.idCardNo ?? '')),
|
idCardNo: maskIdCard(decryptIdCard(elder.idCardNoEnc)),
|
||||||
phone: elder.phone,
|
phone: elder.phone,
|
||||||
address: elder.address,
|
address: elder.address,
|
||||||
insurNo: elder.insurNo,
|
insurNo: elder.insurNo,
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const IRREVERSIBLE_TREAT_STATUSES: readonly string[] = ['dead', 'terminat
|
|||||||
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
|
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
|
||||||
* - birth_date 由身份证 7-14 位带出,不接受前端传入;
|
* - birth_date 由身份证 7-14 位带出,不接受前端传入;
|
||||||
* - treat_status/status_source 由服务端定,不接受前端传入;
|
* - treat_status/status_source 由服务端定,不接受前端传入;
|
||||||
* - id_card_no 本期存明文(加密机制由后续安全 change 统一落地,见 elder.service.ts TODO)。
|
* - id_card_no 前端仅传明文用于建档校验,服务端落库为 id_card_no_enc(AES-256-GCM)+ id_card_no_hash(SHA-256 唯一)。
|
||||||
*/
|
*/
|
||||||
export class CreateElderDto {
|
export class CreateElderDto {
|
||||||
@ApiProperty({ description: '参保地/统筹区 code(loc_code)' })
|
@ApiProperty({ description: '参保地/统筹区 code(loc_code)' })
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { PrismaService } from '../prisma/prisma.service.js';
|
|||||||
import type { StorageService } from '../storage/storage.service.js';
|
import type { StorageService } from '../storage/storage.service.js';
|
||||||
import type { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
import type { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
||||||
import { ErrorCodes } from '../common/error-codes.js';
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
import { encrypt, hash } from '../common/id-card-cipher.js';
|
||||||
import { runWithRequestContext } from '../common/context/request-context.js';
|
import { runWithRequestContext } from '../common/context/request-context.js';
|
||||||
import type { RequestContext } from '../common/context/request-context.js';
|
import type { RequestContext } from '../common/context/request-context.js';
|
||||||
|
|
||||||
@ -183,7 +184,7 @@ describe('ElderService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('同机构重复 id_card_no → 抛 ELDER_ID_CARD_EXISTS', async () => {
|
it('同机构重复 id_card_no → 抛 ELDER_ID_CARD_EXISTS', async () => {
|
||||||
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e-other', idCardNo: VALID_ID_CARD });
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e-other', idCardNoHash: hash(VALID_ID_CARD) });
|
||||||
const elderCreate = vi.fn();
|
const elderCreate = vi.fn();
|
||||||
const { service } = makeService({ elderFindFirst, elderCreate });
|
const { service } = makeService({ elderFindFirst, elderCreate });
|
||||||
|
|
||||||
@ -191,10 +192,21 @@ describe('ElderService', () => {
|
|||||||
runWithRequestContext(ctx, () => service.createElder(baseDto)),
|
runWithRequestContext(ctx, () => service.createElder(baseDto)),
|
||||||
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_ID_CARD_EXISTS } });
|
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_ID_CARD_EXISTS } });
|
||||||
|
|
||||||
expect(elderFindFirst).toHaveBeenCalledWith({ where: { idCardNo: VALID_ID_CARD } });
|
expect(elderFindFirst).toHaveBeenCalledWith({ where: { idCardNoHash: hash(VALID_ID_CARD) } });
|
||||||
expect(elderCreate).not.toHaveBeenCalled();
|
expect(elderCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('create 存 idCardNoHash/idCardNoEnc,不存明文 idCardNo', async () => {
|
||||||
|
const { service, elderCreate } = makeService();
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.createElder(baseDto));
|
||||||
|
|
||||||
|
const data = elderCreate.mock.calls[0][0].data as Record<string, unknown>;
|
||||||
|
expect(data.idCardNoHash).toBe(hash(VALID_ID_CARD));
|
||||||
|
expect(data.idCardNoEnc).toBeTruthy();
|
||||||
|
expect(data.idCardNo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('changeStatus 缺 changeReason → 抛 ELDER_STATUS_REASON_REQUIRED', async () => {
|
it('changeStatus 缺 changeReason → 抛 ELDER_STATUS_REASON_REQUIRED', async () => {
|
||||||
const { service, elderUpdate } = makeService();
|
const { service, elderUpdate } = makeService();
|
||||||
|
|
||||||
@ -264,9 +276,9 @@ describe('ElderService', () => {
|
|||||||
expect(elderCreate).not.toHaveBeenCalled();
|
expect(elderCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list 返回掩码 id_card_no', async () => {
|
it('list 返回掩码 id_card_no,且不泄露密文/哈希列', async () => {
|
||||||
const elderFindMany = vi.fn().mockResolvedValue([
|
const elderFindMany = vi.fn().mockResolvedValue([
|
||||||
{ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三', treatStatus: 'active' },
|
{ id: 'e1', idCardNoEnc: encrypt(VALID_ID_CARD), idCardNoHash: hash(VALID_ID_CARD), name: '张三', treatStatus: 'active' },
|
||||||
]);
|
]);
|
||||||
const elderCount = vi.fn().mockResolvedValue(1);
|
const elderCount = vi.fn().mockResolvedValue(1);
|
||||||
const { service } = makeService({ elderFindMany, elderCount });
|
const { service } = makeService({ elderFindMany, elderCount });
|
||||||
@ -274,28 +286,32 @@ describe('ElderService', () => {
|
|||||||
const res = await runWithRequestContext(ctx, () => service.listElder({ page: 1, pageSize: 20 }));
|
const res = await runWithRequestContext(ctx, () => service.listElder({ page: 1, pageSize: 20 }));
|
||||||
|
|
||||||
expect(res.list[0].idCardNo).toBe('5301**********1231');
|
expect(res.list[0].idCardNo).toBe('5301**********1231');
|
||||||
|
expect(res.list[0].idCardNoEnc).toBeUndefined();
|
||||||
|
expect(res.list[0].idCardNoHash).toBeUndefined();
|
||||||
expect(res.total).toBe(1);
|
expect(res.total).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detail 非 admin/qc 不返回明文', async () => {
|
it('detail 非 admin/qc 不返回明文', async () => {
|
||||||
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三' });
|
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNoEnc: encrypt(VALID_ID_CARD), idCardNoHash: hash(VALID_ID_CARD), name: '张三' });
|
||||||
const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'nurse' }]);
|
const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'nurse' }]);
|
||||||
const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany });
|
const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany });
|
||||||
|
|
||||||
const res = await runWithRequestContext(ctx, () => service.detail('e1'));
|
const res = await runWithRequestContext(ctx, () => service.detail('e1'));
|
||||||
|
|
||||||
expect(res.idCardNo).toBe('5301**********1231');
|
expect(res.idCardNo).toBe('5301**********1231');
|
||||||
|
expect(res.idCardNoEnc).toBeUndefined();
|
||||||
expect(auditRecord).not.toHaveBeenCalled();
|
expect(auditRecord).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detail admin/qc 返回明文并写审计', async () => {
|
it('detail admin/qc 返回明文并写审计', async () => {
|
||||||
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三' });
|
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNoEnc: encrypt(VALID_ID_CARD), idCardNoHash: hash(VALID_ID_CARD), name: '张三' });
|
||||||
const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'admin' }]);
|
const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'admin' }]);
|
||||||
const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany });
|
const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany });
|
||||||
|
|
||||||
const res = await runWithRequestContext(ctx, () => service.detail('e1'));
|
const res = await runWithRequestContext(ctx, () => service.detail('e1'));
|
||||||
|
|
||||||
expect(res.idCardNo).toBe(VALID_ID_CARD);
|
expect(res.idCardNo).toBe(VALID_ID_CARD);
|
||||||
|
expect(res.idCardNoEnc).toBeUndefined();
|
||||||
expect(auditRecord).toHaveBeenCalledWith(
|
expect(auditRecord).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ module: 'elder', action: 'view-id-card', userId: 'u1' }),
|
expect.objectContaining({ module: 'elder', action: 'view-id-card', userId: 'u1' }),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { BusinessException } from '../common/business-exception.js';
|
|||||||
import { ErrorCodes } from '../common/error-codes.js';
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
import { StorageService } from '../storage/storage.service.js';
|
import { StorageService } from '../storage/storage.service.js';
|
||||||
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
||||||
|
import { decryptIdCard, encrypt, hash } from '../common/id-card-cipher.js';
|
||||||
import { ChangeStatusDto, CreateElderDto, IRREVERSIBLE_TREAT_STATUSES, ListElderQuery } from './dto.js';
|
import { ChangeStatusDto, CreateElderDto, IRREVERSIBLE_TREAT_STATUSES, ListElderQuery } from './dto.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,7 +83,7 @@ export class ElderService extends BaseCrudService {
|
|||||||
const page = await this.paginate(q.page, q.pageSize, where);
|
const page = await this.paginate(q.page, q.pageSize, where);
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
list: page.list.map((e: Record<string, unknown>) => ({ ...e, idCardNo: maskIdCard(String(e.idCardNo ?? '')) })),
|
list: page.list.map((e: Record<string, unknown>) => this.presentIdCard(e, false)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,10 +104,11 @@ export class ElderService extends BaseCrudService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const treatStatus = resolveInitialStatus(dto.benefitStart, dto.benefitEnd);
|
const treatStatus = resolveInitialStatus(dto.benefitStart, dto.benefitEnd);
|
||||||
// TODO(id_card_no 加密): 本期直接存明文,加密机制由后续 security change 统一落地。
|
const { idCardNo: _idCardNo, ...dtoRest } = dto;
|
||||||
return super.create({
|
const created = await super.create({
|
||||||
...dto,
|
...dtoRest,
|
||||||
idCardNo,
|
idCardNoHash: hash(idCardNo),
|
||||||
|
idCardNoEnc: encrypt(idCardNo),
|
||||||
birthDate: toDateOnly(birthDate),
|
birthDate: toDateOnly(birthDate),
|
||||||
treatStatus,
|
treatStatus,
|
||||||
statusSource: 'manual',
|
statusSource: 'manual',
|
||||||
@ -117,6 +119,7 @@ export class ElderService extends BaseCrudService {
|
|||||||
hospStart: toDateOnly(dto.hospStart),
|
hospStart: toDateOnly(dto.hospStart),
|
||||||
hospEnd: toDateOnly(dto.hospEnd),
|
hospEnd: toDateOnly(dto.hospEnd),
|
||||||
});
|
});
|
||||||
|
return this.presentIdCard(created, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 详情:admin/qc(或平台超管)返回身份证明文并显式写审计;否则掩码。 */
|
/** 详情:admin/qc(或平台超管)返回身份证明文并显式写审计;否则掩码。 */
|
||||||
@ -133,9 +136,9 @@ export class ElderService extends BaseCrudService {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
params: JSON.stringify({ elderId: id, elderName: elder.name }),
|
params: JSON.stringify({ elderId: id, elderName: elder.name }),
|
||||||
});
|
});
|
||||||
return elder;
|
return this.presentIdCard(elder, true);
|
||||||
}
|
}
|
||||||
return { ...elder, idCardNo: maskIdCard(String(elder.idCardNo ?? '')) };
|
return this.presentIdCard(elder, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 变更待遇状态:changeReason 必填;写 elder_status_log(from/to/reason/operator/changedAt)。 */
|
/** 变更待遇状态:changeReason 必填;写 elder_status_log(from/to/reason/operator/changedAt)。 */
|
||||||
@ -203,12 +206,22 @@ export class ElderService extends BaseCrudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async assertIdCardUnique(idCardNo: string) {
|
private async assertIdCardUnique(idCardNo: string) {
|
||||||
const existing = await this.db().findFirst({ where: { idCardNo } });
|
const existing = await this.db().findFirst({ where: { idCardNoHash: hash(idCardNo) } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new BusinessException(ErrorCodes.ELDER_ID_CARD_EXISTS, '已有档案', HttpStatus.CONFLICT);
|
throw new BusinessException(ErrorCodes.ELDER_ID_CARD_EXISTS, '已有档案', HttpStatus.CONFLICT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将存储层记录转成对外返回:剥离 idCardNoEnc / idCardNoHash,解密后按授权级别输出 idCardNo。
|
||||||
|
* plain=true(admin/qc 明文查看)返回明文,否则返回掩码。
|
||||||
|
*/
|
||||||
|
private presentIdCard(record: Record<string, any>, plain: boolean): Record<string, any> {
|
||||||
|
const { idCardNoEnc, idCardNoHash: _hash, ...rest } = record;
|
||||||
|
const idCardNo = decryptIdCard(idCardNoEnc);
|
||||||
|
return { ...rest, idCardNo: plain ? idCardNo : maskIdCard(idCardNo) };
|
||||||
|
}
|
||||||
|
|
||||||
/** 待遇有效期须「止 > 起」。 */
|
/** 待遇有效期须「止 > 起」。 */
|
||||||
private assertBenefitRange(benefitStart?: string, benefitEnd?: string) {
|
private assertBenefitRange(benefitStart?: string, benefitEnd?: string) {
|
||||||
if (benefitStart && benefitEnd && benefitEnd <= benefitStart) {
|
if (benefitStart && benefitEnd && benefitEnd <= benefitStart) {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { PageQuery } from '../common/page.dto.js';
|
|||||||
/**
|
/**
|
||||||
* 人员(staff)DTO。字段字典以 shared/data-model 为准,本期只落地 C0 骨架字段。
|
* 人员(staff)DTO。字段字典以 shared/data-model 为准,本期只落地 C0 骨架字段。
|
||||||
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
|
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
|
||||||
* - id_card_no 本期存明文(加密机制由后续安全 change 统一落地,见 staff.service.ts TODO)。
|
* - id_card_no 前端仅传明文,服务端落库为 id_card_no_enc(AES-256-GCM)+ id_card_no_hash(SHA-256)。
|
||||||
*/
|
*/
|
||||||
export class CreateStaffDto {
|
export class CreateStaffDto {
|
||||||
@ApiPropertyOptional({ description: '关联账号 user.id(可空,1:1;院长/质控/管理员无 staff 记录)' })
|
@ApiPropertyOptional({ description: '关联账号 user.id(可空,1:1;院长/质控/管理员无 staff 记录)' })
|
||||||
@ -18,7 +18,7 @@ export class CreateStaffDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '身份证号(本期明文)' })
|
@ApiProperty({ description: '身份证号' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
idCardNo!: string;
|
idCardNo!: string;
|
||||||
@ -106,7 +106,7 @@ export class UpdateStaffDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '身份证号(本期明文)' })
|
@ApiPropertyOptional({ description: '身份证号' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
idCardNo?: string;
|
idCardNo?: string;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { StaffService } from './staff.service.js';
|
import { StaffService } from './staff.service.js';
|
||||||
import type { PrismaService } from '../prisma/prisma.service.js';
|
import type { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { ErrorCodes } from '../common/error-codes.js';
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
import { encrypt, hash } from '../common/id-card-cipher.js';
|
||||||
import { runWithRequestContext } from '../common/context/request-context.js';
|
import { runWithRequestContext } from '../common/context/request-context.js';
|
||||||
import type { RequestContext } from '../common/context/request-context.js';
|
import type { RequestContext } from '../common/context/request-context.js';
|
||||||
|
|
||||||
@ -27,28 +28,94 @@ const baseDto = {
|
|||||||
function makeService(
|
function makeService(
|
||||||
overrides: {
|
overrides: {
|
||||||
staffFindFirst?: ReturnType<typeof vi.fn>;
|
staffFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
staffFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
userFindFirst?: ReturnType<typeof vi.fn>;
|
userFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
create?: ReturnType<typeof vi.fn>;
|
create?: ReturnType<typeof vi.fn>;
|
||||||
|
update?: ReturnType<typeof vi.fn>;
|
||||||
|
findMany?: ReturnType<typeof vi.fn>;
|
||||||
|
count?: ReturnType<typeof vi.fn>;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const staffFindFirst = overrides.staffFindFirst ?? vi.fn().mockResolvedValue(null);
|
const staffFindFirst = overrides.staffFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const staffFindUnique = overrides.staffFindUnique ?? vi.fn().mockResolvedValue(null);
|
||||||
const userFindFirst = overrides.userFindFirst ?? vi.fn().mockResolvedValue({ id: 'u9', tenantCode: 'T001' });
|
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 update = overrides.update ?? vi.fn().mockResolvedValue({ id: 's1' });
|
||||||
|
const findMany = overrides.findMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
const count = overrides.count ?? vi.fn().mockResolvedValue(0);
|
||||||
const prisma = {
|
const prisma = {
|
||||||
client: { staff: { findFirst: staffFindFirst, create }, user: { findFirst: userFindFirst } },
|
client: {
|
||||||
|
staff: { findFirst: staffFindFirst, findUnique: staffFindUnique, create, update, findMany, count },
|
||||||
|
user: { findFirst: userFindFirst },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new StaffService(prisma as unknown as PrismaService),
|
||||||
|
staffFindFirst,
|
||||||
|
staffFindUnique,
|
||||||
|
userFindFirst,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
findMany,
|
||||||
|
count,
|
||||||
};
|
};
|
||||||
return { service: new StaffService(prisma as unknown as PrismaService), staffFindFirst, userFindFirst, create };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('StaffService', () => {
|
describe('StaffService', () => {
|
||||||
it('create 自动注入 tenantCode/createdBy', async () => {
|
it('create 自动注入 tenantCode/createdBy,身份证加密存储不落明文', async () => {
|
||||||
const { service, create } = makeService();
|
const { service, create } = makeService();
|
||||||
|
|
||||||
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
|
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
|
||||||
|
|
||||||
expect(create).toHaveBeenCalledWith({
|
const data = create.mock.calls[0][0].data as Record<string, unknown>;
|
||||||
data: { ...baseDto, tenantCode: 'T001', createdBy: 'u1' },
|
expect(data).toMatchObject({
|
||||||
|
name: '张三',
|
||||||
|
phone: '13800000000',
|
||||||
|
jobType: 'nurse',
|
||||||
|
workType: 'full',
|
||||||
|
employStatus: 'on',
|
||||||
|
tenantCode: 'T001',
|
||||||
|
createdBy: 'u1',
|
||||||
});
|
});
|
||||||
|
expect(data.idCardNoHash).toBe(hash(baseDto.idCardNo));
|
||||||
|
expect(data.idCardNoEnc).toBeTruthy();
|
||||||
|
expect(data.idCardNo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create 存 idCardNoHash 为 SHA-256、idCardNoEnc 可解密回原文', async () => {
|
||||||
|
const { service, create } = makeService();
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
|
||||||
|
|
||||||
|
const data = create.mock.calls[0][0].data as Record<string, unknown>;
|
||||||
|
expect(data.idCardNoHash).toBe(hash(baseDto.idCardNo));
|
||||||
|
expect(data.idCardNoEnc).not.toContain(baseDto.idCardNo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update 改身份证时重算 idCardNoHash/idCardNoEnc', async () => {
|
||||||
|
const staffFindUnique = vi.fn().mockResolvedValue({ id: 's1', name: '张三' });
|
||||||
|
const { service, update } = makeService({ staffFindUnique });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.updateStaff('s1', { idCardNo: '530102199001011234' }));
|
||||||
|
|
||||||
|
const data = update.mock.calls[0][0].data as Record<string, unknown>;
|
||||||
|
expect(data.idCardNoHash).toBe(hash('530102199001011234'));
|
||||||
|
expect(data.idCardNoEnc).toBeTruthy();
|
||||||
|
expect(data.idCardNo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list 返回掩码 id_card_no,且不泄露密文/哈希列', async () => {
|
||||||
|
const findMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: 's1', name: '张三', idCardNoEnc: encrypt(baseDto.idCardNo), idCardNoHash: hash(baseDto.idCardNo) },
|
||||||
|
]);
|
||||||
|
const count = vi.fn().mockResolvedValue(1);
|
||||||
|
const { service } = makeService({ findMany, count });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.listStaff({ page: 1, pageSize: 20 }));
|
||||||
|
|
||||||
|
expect(res.list[0].idCardNo).toBe('5301**********1234');
|
||||||
|
expect(res.list[0].idCardNoEnc).toBeUndefined();
|
||||||
|
expect(res.list[0].idCardNoHash).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('userId 指向的账号不存在时 create 抛 USER_NOT_FOUND', async () => {
|
it('userId 指向的账号不存在时 create 抛 USER_NOT_FOUND', async () => {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { PrismaService } from '../prisma/prisma.service.js';
|
|||||||
import { getRequestContext } from '../common/context/request-context.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 { decryptIdCard, encrypt, hash } from '../common/id-card-cipher.js';
|
||||||
|
import { maskIdCard } from '../elder/elder.service.js';
|
||||||
import { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.js';
|
import { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,33 +25,51 @@ export class StaffService extends BaseCrudService {
|
|||||||
if (q.name) where.name = { contains: q.name };
|
if (q.name) where.name = { contains: q.name };
|
||||||
if (q.jobType) where.jobType = q.jobType;
|
if (q.jobType) where.jobType = q.jobType;
|
||||||
if (q.employStatus) where.employStatus = q.employStatus;
|
if (q.employStatus) where.employStatus = q.employStatus;
|
||||||
return this.paginate(q.page, q.pageSize, where);
|
return this.paginate(q.page, q.pageSize, where).then((page) => ({
|
||||||
|
...page,
|
||||||
|
list: page.list.map((e: Record<string, unknown>) => this.presentIdCard(e)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 建档:先校验 userId 指向的账号存在且未被占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */
|
/** 建档:先校验 userId 指向的账号存在且未被占用,身份证加密存储后走 BaseCrudService 注入 tenantCode/createdBy。 */
|
||||||
async createStaff(dto: CreateStaffDto) {
|
async createStaff(dto: CreateStaffDto) {
|
||||||
await this.assertUserIdValid(dto.userId);
|
await this.assertUserIdValid(dto.userId);
|
||||||
// TODO(id_card_no 加密): 本期直接存明文,加密机制由后续安全 change 统一落地后再在此处加解密。
|
const { idCardNo, ...rest } = dto;
|
||||||
return super.create({ ...dto });
|
const created = await super.create({
|
||||||
|
...rest,
|
||||||
|
idCardNoHash: hash(idCardNo.trim()),
|
||||||
|
idCardNoEnc: encrypt(idCardNo.trim()),
|
||||||
|
});
|
||||||
|
return this.presentIdCard(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 改人员:改绑 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);
|
||||||
await this.assertUserIdValid(dto.userId, id);
|
await this.assertUserIdValid(dto.userId, id);
|
||||||
return super.update(id, { ...dto });
|
const { idCardNo, ...rest } = dto;
|
||||||
|
const data: Record<string, unknown> = { ...rest };
|
||||||
|
if (idCardNo !== undefined) {
|
||||||
|
const plain = idCardNo.trim();
|
||||||
|
data.idCardNoHash = hash(plain);
|
||||||
|
data.idCardNoEnc = encrypt(plain);
|
||||||
|
}
|
||||||
|
const updated = await super.update(id, data);
|
||||||
|
return this.presentIdCard(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除(软删)。 */
|
/** 删除(软删)。 */
|
||||||
async removeStaff(id: string) {
|
async removeStaff(id: string) {
|
||||||
await this.requireExists(id);
|
await this.requireExists(id);
|
||||||
return this.softRemove(id);
|
const removed = await this.softRemove(id);
|
||||||
|
return this.presentIdCard(removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 指定归属组长(groupLeaderId 传 null 解除归属)。 */
|
/** 指定归属组长(groupLeaderId 传 null 解除归属)。 */
|
||||||
async assignGroup(id: string, dto: AssignGroupDto) {
|
async assignGroup(id: string, dto: AssignGroupDto) {
|
||||||
await this.requireExists(id);
|
await this.requireExists(id);
|
||||||
return super.update(id, { groupLeaderId: dto.groupLeaderId });
|
const updated = await super.update(id, { groupLeaderId: dto.groupLeaderId });
|
||||||
|
return this.presentIdCard(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** userId 为 1:1 关联(可空):非空时校验账号存在 + 未被其他 staff 占用。 */
|
/** userId 为 1:1 关联(可空):非空时校验账号存在 + 未被其他 staff 占用。 */
|
||||||
@ -85,4 +105,10 @@ export class StaffService extends BaseCrudService {
|
|||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 剥离存储字段 idCardNoEnc / idCardNoHash,解密后输出掩码 idCardNo。 */
|
||||||
|
private presentIdCard(record: Record<string, any>): Record<string, any> {
|
||||||
|
const { idCardNoEnc, idCardNoHash: _hash, ...rest } = record;
|
||||||
|
return { ...rest, idCardNo: maskIdCard(decryptIdCard(idCardNoEnc)) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user