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:
liuyun 2026-10-21 12:03:43 +08:00
parent 29fc5e9432
commit 394d7a9731
16 changed files with 345 additions and 45 deletions

View File

@ -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
View 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();
});

View File

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

View File

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

View File

@ -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")
} }

View File

@ -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',

View 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('');
});
});

View 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 '';
}
}

View File

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

View File

@ -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,

View File

@ -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)' })

View File

@ -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' }),
); );

View File

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

View File

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

View File

@ -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 () => {

View File

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