diff --git a/.env.example b/.env.example index 1e64b60..271ab02 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,9 @@ JWT_SECRET="dev-secret-change-me" JWT_ACCESS_TTL="7200" JWT_REFRESH_TTL="604800" +# 身份证号存储层加密密钥(64 hex = 32 字节);生产务必替换为随机值 +DATA_ENC_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + # 日志(详见 src/logging/logger.config.ts) LOG_LEVEL="info" LOG_PRETTY="true" diff --git a/prisma/encrypt-idcard.ts b/prisma/encrypt-idcard.ts new file mode 100644 index 0000000..bd79994 --- /dev/null +++ b/prisma/encrypt-idcard.ts @@ -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 { + const rows = await prisma.$queryRawUnsafe>( + `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 { + 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(); + }); diff --git a/prisma/migrations/20261021120000_idcard_enc_1_add_cols/migration.sql b/prisma/migrations/20261021120000_idcard_enc_1_add_cols/migration.sql new file mode 100644 index 0000000..33dc842 --- /dev/null +++ b/prisma/migrations/20261021120000_idcard_enc_1_add_cols/migration.sql @@ -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; diff --git a/prisma/migrations/20261021130000_idcard_enc_2_drop_plain/migration.sql b/prisma/migrations/20261021130000_idcard_enc_2_drop_plain/migration.sql new file mode 100644 index 0000000..b72267b --- /dev/null +++ b/prisma/migrations/20261021130000_idcard_enc_2_drop_plain/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 625af8a..cbeb4fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -331,7 +331,8 @@ model Staff { tenantCode String @map("tenant_code") @db.VarChar(64) userId String? @unique @map("user_id") @db.VarChar(36) 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) phone String @db.VarChar(15) jobType String @map("job_type") @db.VarChar(20) @@ -440,7 +441,8 @@ model Elder { name String @db.VarChar(30) gender String @db.Char(1) 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) address String @db.VarChar(200) guardianName String? @map("guardian_name") @db.VarChar(30) @@ -471,7 +473,7 @@ model Elder { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") - @@unique([tenantCode, idCardNo]) + @@unique([tenantCode, idCardNoHash]) @@index([tenantCode, name]) @@map("ltc_elder") } diff --git a/prisma/seed-demo.ts b/prisma/seed-demo.ts index c35d2ba..20d4948 100644 --- a/prisma/seed-demo.ts +++ b/prisma/seed-demo.ts @@ -2,6 +2,7 @@ import { Prisma, PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; import { TENANT_ADMIN_ROLE_CODE } from '../src/common/constants.js'; import { initTenant } from '../src/common/tenant-template.js'; +import { encrypt, hash } from '../src/common/id-card-cipher.js'; /** * 测试机构(TY20261015 投云养老服务机构)演示数据 seed。 @@ -174,12 +175,14 @@ async function main(): Promise { // 3. staff:1 组长(刘组长,绑 leader01)+ 3 护理员(张/李/王,张护理绑 nurse01) const leaderId = IDS.staffLeader; + const leaderIdCard = idCard('53010219850101004'); await prisma.staff.upsert({ where: { id: leaderId }, update: { userId: userIds.leader01, name: '刘组长', - idCardNo: idCard('53010219850101004'), + idCardNoHash: hash(leaderIdCard), + idCardNoEnc: encrypt(leaderIdCard), jobType: 'leader', workType: 'fulltime', employStatus: 'on_job', @@ -190,7 +193,8 @@ async function main(): Promise { tenantCode: TENANT_CODE, userId: userIds.leader01, name: '刘组长', - idCardNo: idCard('53010219850101004'), + idCardNoHash: hash(leaderIdCard), + idCardNoEnc: encrypt(leaderIdCard), phone: '13800000004', jobType: 'leader', workType: 'fulltime', @@ -207,12 +211,14 @@ async function main(): Promise { ]; const nurseStaffIds: Record = {}; for (const nd of nurseDefs) { + const ndIdCard = idCard(nd.idPrefix); await prisma.staff.upsert({ where: { id: nd.id }, update: { userId: nd.userId, name: nd.name, - idCardNo: idCard(nd.idPrefix), + idCardNoHash: hash(ndIdCard), + idCardNoEnc: encrypt(ndIdCard), jobType: 'nurse', workType: 'fulltime', employStatus: 'on_job', @@ -223,7 +229,8 @@ async function main(): Promise { tenantCode: TENANT_CODE, userId: nd.userId, name: nd.name, - idCardNo: idCard(nd.idPrefix), + idCardNoHash: hash(ndIdCard), + idCardNoEnc: encrypt(ndIdCard), phone: nd.phone, jobType: 'nurse', workType: 'fulltime', @@ -277,7 +284,7 @@ async function main(): Promise { // 5. 老人:张三(active)、李四(waiting_period,垫付演示) 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: {}, create: { tenantCode: TENANT_CODE, @@ -285,7 +292,8 @@ async function main(): Promise { name: '张三', gender: 'M', birthDate: dateOnly('1990-01-01'), - idCardNo: ID_ZHANG, + idCardNoHash: hash(ID_ZHANG), + idCardNoEnc: encrypt(ID_ZHANG), phone: '13900000001', address: '昆明市五华区春城路 1 号', guardianName: '张小明', @@ -301,7 +309,7 @@ async function main(): Promise { }); await prisma.elder.upsert({ - where: { tenantCode_idCardNo: { tenantCode: TENANT_CODE, idCardNo: ID_LI } }, + where: { tenantCode_idCardNoHash: { tenantCode: TENANT_CODE, idCardNoHash: hash(ID_LI) } }, update: {}, create: { tenantCode: TENANT_CODE, @@ -309,7 +317,8 @@ async function main(): Promise { name: '李四', gender: 'F', birthDate: dateOnly('1990-03-03'), - idCardNo: ID_LI, + idCardNoHash: hash(ID_LI), + idCardNoEnc: encrypt(ID_LI), phone: '13900000003', address: '昆明市盘龙区东风路 2 号', insurNo: 'YN5301000002', diff --git a/src/common/id-card-cipher.spec.ts b/src/common/id-card-cipher.spec.ts new file mode 100644 index 0000000..ce1925b --- /dev/null +++ b/src/common/id-card-cipher.spec.ts @@ -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(''); + }); +}); diff --git a/src/common/id-card-cipher.ts b/src/common/id-card-cipher.ts new file mode 100644 index 0000000..7291eb0 --- /dev/null +++ b/src/common/id-card-cipher.ts @@ -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 ''; + } +} diff --git a/src/document/document.service.spec.ts b/src/document/document.service.spec.ts index b25cf5f..0d8f8ff 100644 --- a/src/document/document.service.spec.ts +++ b/src/document/document.service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { DocumentService, EXPORT_NOTICE } from './document.service.js'; import type { PrismaService } from '../prisma/prisma.service.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 type { RequestContext } from '../common/context/request-context.js'; @@ -75,7 +76,7 @@ describe('DocumentService.export', () => { name: '张三', gender: '1', birthDate: new Date('1950-01-01T00:00:00Z'), - idCardNo: '530102195001010011', + idCardNoEnc: encrypt('530102195001010011'), phone: '13800000000', address: '昆明市', insurNo: 'I001', @@ -206,7 +207,7 @@ describe('DocumentService.export', () => { }); 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 { service } = makeService({ elderFindUnique: vi.fn().mockResolvedValue(elder), diff --git a/src/document/document.service.ts b/src/document/document.service.ts index 78a6836..2c22673 100644 --- a/src/document/document.service.ts +++ b/src/document/document.service.ts @@ -4,6 +4,7 @@ import { getRequestContext } from '../common/context/request-context.js'; import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; import { maskIdCard } from '../elder/elder.service.js'; +import { decryptIdCard } from '../common/id-card-cipher.js'; /** 导出水印固定文案(R4:只用「日常留痕,随时可导出」,禁用迎检/应对巡查等词)。 */ export const EXPORT_NOTICE = '日常留痕,随时可导出'; @@ -101,7 +102,7 @@ export class DocumentService { name: elder.name, gender: elder.gender, birthDate: fmtDate(elder.birthDate), - idCardNo: maskIdCard(String(elder.idCardNo ?? '')), + idCardNo: maskIdCard(decryptIdCard(elder.idCardNoEnc)), phone: elder.phone, address: elder.address, insurNo: elder.insurNo, diff --git a/src/elder/dto.ts b/src/elder/dto.ts index 37908bb..ed53507 100644 --- a/src/elder/dto.ts +++ b/src/elder/dto.ts @@ -24,7 +24,7 @@ export const IRREVERSIBLE_TREAT_STATUSES: readonly string[] = ['dead', 'terminat * - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入); * - birth_date 由身份证 7-14 位带出,不接受前端传入; * - 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 { @ApiProperty({ description: '参保地/统筹区 code(loc_code)' }) diff --git a/src/elder/elder.service.spec.ts b/src/elder/elder.service.spec.ts index 2dcff78..b867546 100644 --- a/src/elder/elder.service.spec.ts +++ b/src/elder/elder.service.spec.ts @@ -4,6 +4,7 @@ import type { PrismaService } from '../prisma/prisma.service.js'; import type { StorageService } from '../storage/storage.service.js'; import type { AuditLogsService } from '../audit-logs/audit-logs.service.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 type { RequestContext } from '../common/context/request-context.js'; @@ -183,7 +184,7 @@ describe('ElderService', () => { }); 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 { service } = makeService({ elderFindFirst, elderCreate }); @@ -191,10 +192,21 @@ describe('ElderService', () => { runWithRequestContext(ctx, () => service.createElder(baseDto)), ).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(); }); + 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; + expect(data.idCardNoHash).toBe(hash(VALID_ID_CARD)); + expect(data.idCardNoEnc).toBeTruthy(); + expect(data.idCardNo).toBeUndefined(); + }); + it('changeStatus 缺 changeReason → 抛 ELDER_STATUS_REASON_REQUIRED', async () => { const { service, elderUpdate } = makeService(); @@ -264,9 +276,9 @@ describe('ElderService', () => { expect(elderCreate).not.toHaveBeenCalled(); }); - it('list 返回掩码 id_card_no', async () => { + it('list 返回掩码 id_card_no,且不泄露密文/哈希列', async () => { 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 { service } = makeService({ elderFindMany, elderCount }); @@ -274,28 +286,32 @@ describe('ElderService', () => { const res = await runWithRequestContext(ctx, () => service.listElder({ page: 1, pageSize: 20 })); 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); }); 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 { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany }); const res = await runWithRequestContext(ctx, () => service.detail('e1')); expect(res.idCardNo).toBe('5301**********1231'); + expect(res.idCardNoEnc).toBeUndefined(); expect(auditRecord).not.toHaveBeenCalled(); }); 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 { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany }); const res = await runWithRequestContext(ctx, () => service.detail('e1')); expect(res.idCardNo).toBe(VALID_ID_CARD); + expect(res.idCardNoEnc).toBeUndefined(); expect(auditRecord).toHaveBeenCalledWith( expect.objectContaining({ module: 'elder', action: 'view-id-card', userId: 'u1' }), ); diff --git a/src/elder/elder.service.ts b/src/elder/elder.service.ts index c21a258..16586a8 100644 --- a/src/elder/elder.service.ts +++ b/src/elder/elder.service.ts @@ -6,6 +6,7 @@ import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; import { StorageService } from '../storage/storage.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'; /** @@ -82,7 +83,7 @@ export class ElderService extends BaseCrudService { const page = await this.paginate(q.page, q.pageSize, where); return { ...page, - list: page.list.map((e: Record) => ({ ...e, idCardNo: maskIdCard(String(e.idCardNo ?? '')) })), + list: page.list.map((e: Record) => this.presentIdCard(e, false)), }; } @@ -103,10 +104,11 @@ export class ElderService extends BaseCrudService { ); } const treatStatus = resolveInitialStatus(dto.benefitStart, dto.benefitEnd); - // TODO(id_card_no 加密): 本期直接存明文,加密机制由后续 security change 统一落地。 - return super.create({ - ...dto, - idCardNo, + const { idCardNo: _idCardNo, ...dtoRest } = dto; + const created = await super.create({ + ...dtoRest, + idCardNoHash: hash(idCardNo), + idCardNoEnc: encrypt(idCardNo), birthDate: toDateOnly(birthDate), treatStatus, statusSource: 'manual', @@ -117,6 +119,7 @@ export class ElderService extends BaseCrudService { hospStart: toDateOnly(dto.hospStart), hospEnd: toDateOnly(dto.hospEnd), }); + return this.presentIdCard(created, false); } /** 详情:admin/qc(或平台超管)返回身份证明文并显式写审计;否则掩码。 */ @@ -133,9 +136,9 @@ export class ElderService extends BaseCrudService { status: 'success', 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)。 */ @@ -203,12 +206,22 @@ export class ElderService extends BaseCrudService { } 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) { throw new BusinessException(ErrorCodes.ELDER_ID_CARD_EXISTS, '已有档案', HttpStatus.CONFLICT); } } + /** + * 将存储层记录转成对外返回:剥离 idCardNoEnc / idCardNoHash,解密后按授权级别输出 idCardNo。 + * plain=true(admin/qc 明文查看)返回明文,否则返回掩码。 + */ + private presentIdCard(record: Record, plain: boolean): Record { + const { idCardNoEnc, idCardNoHash: _hash, ...rest } = record; + const idCardNo = decryptIdCard(idCardNoEnc); + return { ...rest, idCardNo: plain ? idCardNo : maskIdCard(idCardNo) }; + } + /** 待遇有效期须「止 > 起」。 */ private assertBenefitRange(benefitStart?: string, benefitEnd?: string) { if (benefitStart && benefitEnd && benefitEnd <= benefitStart) { diff --git a/src/staff/dto.ts b/src/staff/dto.ts index a71fea6..61c2460 100644 --- a/src/staff/dto.ts +++ b/src/staff/dto.ts @@ -5,7 +5,7 @@ import { PageQuery } from '../common/page.dto.js'; /** * 人员(staff)DTO。字段字典以 shared/data-model 为准,本期只落地 C0 骨架字段。 * - 不含 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 { @ApiPropertyOptional({ description: '关联账号 user.id(可空,1:1;院长/质控/管理员无 staff 记录)' }) @@ -18,7 +18,7 @@ export class CreateStaffDto { @IsNotEmpty() name!: string; - @ApiProperty({ description: '身份证号(本期明文)' }) + @ApiProperty({ description: '身份证号' }) @IsString() @IsNotEmpty() idCardNo!: string; @@ -106,7 +106,7 @@ export class UpdateStaffDto { @IsString() name?: string; - @ApiPropertyOptional({ description: '身份证号(本期明文)' }) + @ApiPropertyOptional({ description: '身份证号' }) @IsOptional() @IsString() idCardNo?: string; diff --git a/src/staff/staff.service.spec.ts b/src/staff/staff.service.spec.ts index b3d1a70..8aa0a44 100644 --- a/src/staff/staff.service.spec.ts +++ b/src/staff/staff.service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { StaffService } from './staff.service.js'; import type { PrismaService } from '../prisma/prisma.service.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 type { RequestContext } from '../common/context/request-context.js'; @@ -27,28 +28,94 @@ const baseDto = { function makeService( overrides: { staffFindFirst?: ReturnType; + staffFindUnique?: ReturnType; userFindFirst?: ReturnType; create?: ReturnType; + update?: ReturnType; + findMany?: ReturnType; + count?: ReturnType; } = {}, ) { 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 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 = { - 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', () => { - it('create 自动注入 tenantCode/createdBy', async () => { + it('create 自动注入 tenantCode/createdBy,身份证加密存储不落明文', async () => { const { service, create } = makeService(); await runWithRequestContext(ctx, () => service.createStaff(baseDto)); - expect(create).toHaveBeenCalledWith({ - data: { ...baseDto, tenantCode: 'T001', createdBy: 'u1' }, + const data = create.mock.calls[0][0].data as Record; + 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; + 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; + 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 () => { diff --git a/src/staff/staff.service.ts b/src/staff/staff.service.ts index cb0804f..bee0f55 100644 --- a/src/staff/staff.service.ts +++ b/src/staff/staff.service.ts @@ -4,6 +4,8 @@ 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 { decryptIdCard, encrypt, hash } from '../common/id-card-cipher.js'; +import { maskIdCard } from '../elder/elder.service.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.jobType) where.jobType = q.jobType; 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) => this.presentIdCard(e)), + })); } - /** 建档:先校验 userId 指向的账号存在且未被占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */ + /** 建档:先校验 userId 指向的账号存在且未被占用,身份证加密存储后走 BaseCrudService 注入 tenantCode/createdBy。 */ async createStaff(dto: CreateStaffDto) { await this.assertUserIdValid(dto.userId); - // TODO(id_card_no 加密): 本期直接存明文,加密机制由后续安全 change 统一落地后再在此处加解密。 - return super.create({ ...dto }); + const { idCardNo, ...rest } = 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) { await this.requireExists(id); await this.assertUserIdValid(dto.userId, id); - return super.update(id, { ...dto }); + const { idCardNo, ...rest } = dto; + const data: Record = { ...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) { await this.requireExists(id); - return this.softRemove(id); + const removed = await this.softRemove(id); + return this.presentIdCard(removed); } /** 指定归属组长(groupLeaderId 传 null 解除归属)。 */ async assignGroup(id: string, dto: AssignGroupDto) { 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 占用。 */ @@ -85,4 +105,10 @@ export class StaffService extends BaseCrudService { } return existing; } + + /** 剥离存储字段 idCardNoEnc / idCardNoHash,解密后输出掩码 idCardNo。 */ + private presentIdCard(record: Record): Record { + const { idCardNoEnc, idCardNoHash: _hash, ...rest } = record; + return { ...rest, idCardNo: maskIdCard(decryptIdCard(idCardNoEnc)) }; + } }