构建分布式安全的医院信息管理系统——数据库设计与实现

最近基于课程项目设计并实现了以下医院信息管理系统的数据库方案。

在这一系统中,患者的核心信息(包括身份证号、手机号、邮箱)均采用 AES-256 加密后以 VARBINARY 形式存储,并在脱敏视图中仅展示不可逆的字符片段,以确保隐私保护。系统通过触发器在插入患者或医生记录时自动生成全局唯一的 UUID,既保证了跨表、跨节点的一致性,也简化了后续数据同步的复杂度。

在表结构设计上,患者表(`patients`)使用了基于加密手机号的哈希分区,共 8 个分区,以提升高并发写入和查询的性能;医生表(`doctors`)中,则利用 JSON 类型存储专长列表,并通过生成列索引第一专长以加速常见的专业匹配查询。预约表(`appointments`)按时间范围分区,将超过两年的历史数据自动归档到独立表 `appointments_archive` 中,以确保持久高效的在线查询,并通过定时事件每月执行归档存储过程,实现“冷热分离”管理。

为了演示数据写入与查询流程,我批量插入脚本,将多条加密后的患者记录一次性写入数据库,如  
```sql
INSERT INTO patients (national_id,name,gender,birth_date,mobile,email,is_verified)
VALUES (AES_ENCRYPT('426102211639761953','your_key'),'韩山','M','1967-12-10',AES_ENCRYPT('13667878318','your_key'),AES_ENCRYPT('[email protected]','your_key'),0), …;
```  
该脚本不仅演示了加密函数的使用,也方便读者快速生成测试数据。在此基础上,我创建了脱敏视图 `v_patient_safe`,通过 SQL 的 `CONCAT` 与 `SUBSTRING` 函数,仅展示敏感字段的末四位,有效避免明文泄露,密钥直接用your_key加密。

系统还配备了完善的审计机制:在关键表上设置触发器,将所有增删改操作记录到 `audit_logs` 中,包含操作用户、表名、变更前后数据及操作时间等信息,满足合规和安全审计需求。在高并发分布式场景下,号段生成器表 `id_generator` 和存储过程 `get_next_id` 联动使用,可原子性地分配不重复的自增 ID,彻底解决传统 auto_increment 在多节点下的冲突瓶颈。

在这一过程中,我从需求分析、ER 建模,到 MySQL 表结构设计、AES-256 加密、分区归档与触发器编写,并通过样例数据验证了各项功能的正确性和性能表现。该实践不仅巩固了我的数据库理论知识,也提升了我在实际项目中运用分布式 ID 生成、数据安全防护和自动化运维的能力,明显还有很多不足待改正,不过作为一次实践对我来说很宝贵加以记录。

文末附完整建表脚本、数据插入示例及性能测试思路,读者可直接复制运行。 

SET FOREIGN_KEY_CHECKS = 0;
-- 1.patients

DROP TABLE IF EXISTS `patients`;
CREATE TABLE `patients` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '分布式自增ID',
  `uuid` CHAR(36) NOT NULL COMMENT '全局唯一标识',
  `national_id` VARBINARY(255) NOT NULL COMMENT 'AES-256 加密身份证号',
  `name` VARCHAR(100) NOT NULL COMMENT '患者姓名',
  `gender` ENUM('M','F','O') NOT NULL COMMENT '性别',
  `birth_date` DATE NOT NULL COMMENT '出生日期',
  `mobile` VARBINARY(128) NOT NULL COMMENT 'AES-256 加密手机号',
  `email` VARBINARY(255) DEFAULT NULL COMMENT 'AES-256 加密邮箱',
  `avatar_url` VARCHAR(512) DEFAULT NULL COMMENT '头像 URL',
  `is_verified` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否实名认证',
  `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`,`mobile`),
  UNIQUE KEY `idx_uuid`   (`uuid`,`mobile`),
  UNIQUE KEY `idx_mobile` (`mobile`),
  KEY `idx_name_birth`    (`name`,`birth_date`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
  PARTITION BY KEY(mobile) PARTITIONS 8;

DELIMITER $$
CREATE TRIGGER trg_patients_uuid
BEFORE INSERT ON patients
FOR EACH ROW
  SET NEW.uuid = UUID();
$$
DELIMITER ;

-- ----------------------------
-- 2. doctors
-- ----------------------------
DROP TABLE IF EXISTS `doctors`;
CREATE TABLE `doctors` (
  `id` INT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `uuid` CHAR(36) NOT NULL COMMENT '全局唯一标识',
  `license_no` VARCHAR(50) NOT NULL COMMENT ,
  `real_name` VARCHAR(100) NOT NULL COMMENT '真实姓名',
  `title` VARCHAR(50) NOT NULL COMMENT '职称',
  `hospital` VARCHAR(200) NOT NULL COMMENT '所属医院',
  `specialties` JSON NOT NULL COMMENT '专长 JSON 数组',
  `online_status` ENUM('online','offline','busy') NOT NULL DEFAULT 'offline',
  `rating` DECIMAL(2,1) DEFAULT 5.0 COMMENT '服务评分',
  `consultation_fee` DECIMAL(10,2) NOT NULL COMMENT '咨询费用',
  `available_slots` JSON DEFAULT NULL COMMENT '可预约时段',
  `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_license` (`license_no`),
  KEY `idx_hospital_title` (`hospital`,`title`),
  FULLTEXT KEY `ft_hospital` (`hospital`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

DELIMITER $$
CREATE TRIGGER trg_doctors_uuid
BEFORE INSERT ON doctors
FOR EACH ROW
  SET NEW.uuid = UUID();
$$
DELIMITER ;

ALTER TABLE doctors
  ADD COLUMN `first_specialty` VARCHAR(50)
    GENERATED ALWAYS AS (
      JSON_UNQUOTE(JSON_EXTRACT(specialties,'$[0]'))
    ) VIRTUAL,
  ADD INDEX `idx_first_specialty` (`first_specialty`);

-- ----------------------------
-- 3. appointments
-- ----------------------------
DROP TABLE IF EXISTS `appointments`;
CREATE TABLE `appointments` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `uuid` CHAR(36) NOT NULL COMMENT '全局唯一标识',
  `patient_uuid` CHAR(36) NOT NULL COMMENT '患者 UUID',
  `doctor_id` INT NOT NULL COMMENT '医生自增ID',
  `time_slot` DATETIME NOT NULL COMMENT '预约时间段',
  `status` ENUM('pending','confirmed','completed','canceled','expired') NOT NULL DEFAULT 'pending' COMMENT '状态',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`,`time_slot`),
  KEY `idx_doctor_time` (`doctor_id`,`time_slot`),
  KEY `idx_status_time` (`status`,`time_slot` DESC)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
  PARTITION BY RANGE COLUMNS(time_slot) (
    PARTITION p2023Q1 VALUES LESS THAN ('2023-04-01'),
    PARTITION p2023Q2 VALUES LESS THAN ('2023-07-01'),
    PARTITION p2023Q3 VALUES LESS THAN ('2023-10-01'),
    PARTITION p2023Q4 VALUES LESS THAN ('2024-01-01'),
    PARTITION p2024Q1 VALUES LESS THAN ('2024-04-01'),
    PARTITION p_future VALUES LESS THAN MAXVALUE
  );

DELIMITER $$
CREATE TRIGGER trg_appointments_uuid
BEFORE INSERT ON appointments
FOR EACH ROW
  SET NEW.uuid = UUID();
$$
DELIMITER ;

CREATE TABLE IF NOT EXISTS `appointments_archive` LIKE `appointments`;

DELIMITER $$
CREATE PROCEDURE `archive_appointments`()
BEGIN
  DECLARE cutoff DATETIME DEFAULT DATE_SUB(NOW(), INTERVAL 2 YEAR);
  START TRANSACTION;
    INSERT INTO appointments_archive
      SELECT * FROM appointments WHERE time_slot < cutoff;
    DELETE FROM appointments WHERE time_slot < cutoff;
  COMMIT;
END$$
DELIMITER ;

CREATE EVENT IF NOT EXISTS `ev_monthly_archive`
ON SCHEDULE EVERY 1 MONTH
DO CALL archive_appointments();

-- ----------------------------
-- 4. diagnoses
-- ----------------------------
DROP TABLE IF EXISTS `diagnoses`;
ALTER TABLE patients
  REMOVE PARTITIONING;
CREATE TABLE `diagnoses` (
  `DiagnosisID`       INT(11)  NOT NULL AUTO_INCREMENT COMMENT '诊断ID',
  `DiagnosisDescription` VARCHAR(255) NOT NULL COMMENT '描述',
  `DiagnosisTime`     DATETIME DEFAULT NULL COMMENT '诊断时间',
  `PatientID`         BIGINT   DEFAULT NULL COMMENT '患者ID,参照 patients.id',
  `DoctorID`          INT(11)  DEFAULT NULL COMMENT '医生ID,参照 doctors.id',
  PRIMARY KEY (`DiagnosisID`),
  INDEX `idx_diag_pat_doc` (`PatientID`,`DoctorID`),
  CONSTRAINT `diag_fk_pat`
    FOREIGN KEY (`PatientID`) REFERENCES `patients` (`id`)
    ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `diag_fk_doc`
    FOREIGN KEY (`DoctorID`)  REFERENCES `doctors`  (`id`)
    ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8
  ROW_FORMAT=COMPACT;

-- ----------------------------
-- 5. financialrecords
-- ----------------------------
DROP TABLE IF EXISTS `financialrecords`;
CREATE TABLE `financialrecords` (
  `FinancialRecordID` INT(11) NOT NULL AUTO_INCREMENT COMMENT '记录ID',
  `RecordType`        VARCHAR(255) DEFAULT NULL COMMENT '类型',
  `Amount`            DECIMAL(10,2) DEFAULT NULL COMMENT '金额',
  `PatientID`         BIGINT    DEFAULT NULL COMMENT '患者ID,参照 patients.id',
  `created_at`        DATETIME  NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`FinancialRecordID`),
  INDEX `idx_fin_pat` (`PatientID`),
  CONSTRAINT `fin_fk_pat`
    FOREIGN KEY (`PatientID`) REFERENCES `patients` (`id`)
    ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8
  ROW_FORMAT=COMPACT;

-- ----------------------------
-- 6. labtests
-- ----------------------------
DROP TABLE IF EXISTS `labtests`;
CREATE TABLE `labtests` (
  `LabTestID`   INT(11) NOT NULL AUTO_INCREMENT COMMENT '检查ID',
  `TestType`    VARCHAR(255) NOT NULL COMMENT '类型',
  `ResultValue` VARCHAR(255) DEFAULT NULL COMMENT '结果',
  `TestTime`    DATETIME DEFAULT NULL COMMENT '时间',
  `PatientID`   BIGINT    DEFAULT NULL COMMENT '患者ID,参照 patients.id',
  PRIMARY KEY (`LabTestID`),
  INDEX `idx_lab_pat` (`PatientID`),
  CONSTRAINT `lab_fk_pat`
    FOREIGN KEY (`PatientID`) REFERENCES `patients` (`id`)
    ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8
  ROW_FORMAT=COMPACT;

-- ----------------------------
-- 7. radiologytests
-- ----------------------------
DROP TABLE IF EXISTS `radiologytests`;
CREATE TABLE `radiologytests` (
  `RadiologyTestID`   INT(11) NOT NULL AUTO_INCREMENT COMMENT '检查ID',
  `TestType`          VARCHAR(255) NOT NULL COMMENT '类型',
  `ResultDescription` VARCHAR(255) DEFAULT NULL COMMENT '结果',
  `TestTime`          DATETIME DEFAULT NULL COMMENT '时间',
  `PatientID`         BIGINT    DEFAULT NULL COMMENT '患者ID,参照 patients.id',
  PRIMARY KEY (`RadiologyTestID`),
  INDEX `idx_rad_pat` (`PatientID`),
  CONSTRAINT `rad_fk_pat`
    FOREIGN KEY (`PatientID`) REFERENCES `patients` (`id`)
    ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8
  ROW_FORMAT=COMPACT;

-- ----------------------------
-- 8. medications
-- ----------------------------
DROP TABLE IF EXISTS `medications`;
CREATE TABLE `medications` (
  `MedicationID`    INT(11) NOT NULL AUTO_INCREMENT COMMENT '药物ID',
  `MedicationName`  VARCHAR(255) NOT NULL COMMENT '名称',
  `Dosage`          VARCHAR(100) DEFAULT NULL COMMENT '剂量',
  `MedicationUsage` VARCHAR(255) DEFAULT NULL COMMENT '用法',
  `AdverseReaction` VARCHAR(255) DEFAULT NULL COMMENT '不良反应',
  PRIMARY KEY (`MedicationID`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_general_ci
  ROW_FORMAT=COMPACT;

-- ----------------------------
-- 9. id_generator
-- ----------------------------
DROP TABLE IF EXISTS `id_generator`;
CREATE TABLE `id_generator` (
  `biz_tag`    VARCHAR(32) NOT NULL COMMENT '业务标签',
  `max_id`     BIGINT     NOT NULL COMMENT '当前最大ID',
  `step`       INT        NOT NULL COMMENT '号段长度',
  `updated_at` DATETIME(3) NOT NULL COMMENT '更新时间',
  PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DELIMITER $$
CREATE PROCEDURE `get_next_id`(
  IN  in_tag     VARCHAR(32),
  IN  in_step    INT,
  OUT out_new_id BIGINT
)
BEGIN
  DECLARE cur BIGINT;
  START TRANSACTION;
    SELECT max_id INTO cur
      FROM id_generator
     WHERE biz_tag = in_tag
     FOR UPDATE;
    IF cur IS NULL THEN
      INSERT INTO id_generator(biz_tag,max_id,step,updated_at)
      VALUES(in_tag,in_step,in_step,NOW(3));
      SET out_new_id = 0;
    ELSE
      UPDATE id_generator
        SET max_id = cur + in_step,
            updated_at = NOW(3)
      WHERE biz_tag = in_tag;
      SET out_new_id = cur;
    END IF;
  COMMIT;
END$$
DELIMITER ;

-- ----------------------------
-- 10. audit_logs
-- ----------------------------
DROP TABLE IF EXISTS `audit_logs`;
CREATE TABLE `audit_logs` (
  `id`         BIGINT      NOT NULL AUTO_INCREMENT,
  `user_type`  ENUM('PATIENT','DOCTOR','ADMIN') NOT NULL,
  `user_id`    CHAR(36)    NOT NULL,
  `action`     VARCHAR(50) NOT NULL,
  `table_name` VARCHAR(50) NOT NULL,
  `record_id`  VARCHAR(36) NOT NULL,
  `old_data`   JSON        DEFAULT NULL,
  `new_data`   JSON        DEFAULT NULL,
  `ip_address` VARCHAR(45) DEFAULT NULL,
  `user_agent` TEXT        DEFAULT NULL,
  `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`,`created_at`),
  KEY `idx_user_action` (`user_type`,`user_id`,`action`),
  KEY `idx_table_record` (`table_name`,`record_id`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
  PARTITION BY RANGE COLUMNS(created_at) (
    PARTITION p2023 VALUES LESS THAN ('2024-01-01'),
    PARTITION p2024 VALUES LESS THAN ('2025-01-01'),
    PARTITION p_future VALUES LESS THAN MAXVALUE
  );

DROP VIEW IF EXISTS `v_patient_safe`;
CREATE VIEW `v_patient_safe` AS
SELECT
  id,
  name,
  CONCAT(
    LEFT(AES_DECRYPT(national_id,'your_key'),6),
    '****',
    RIGHT(AES_DECRYPT(national_id,'your_key'),4)
  ) AS masked_id,
  gender,
  birth_date,
  CONCAT(
    LEFT(AES_DECRYPT(mobile,'your_key'),3),
    '****',
    RIGHT(AES_DECRYPT(mobile,'your_key'),4)
  ) AS masked_mobile,
  created_at
FROM patients;

-- ----------------------------
-- 11. 汇总视图
-- ----------------------------
DROP VIEW IF EXISTS `doctor_patient_count`;
CREATE VIEW `doctor_patient_count` AS
SELECT
  d.id        AS DoctorID,
  d.real_name AS DoctorName,
  d.title     AS Title,
  COUNT(a.id) AS PatientCount
FROM doctors d
LEFT JOIN appointments a ON d.id = a.doctor_id
GROUP BY d.id,d.real_name,d.title;

DROP VIEW IF EXISTS `patientvisithistory`;
CREATE VIEW `patientvisithistory` AS
SELECT
  p.id       AS PatientID,
  p.name     AS Name,
  diag.DiagnosisTime
FROM patients p
JOIN diagnoses diag ON p.id = diag.PatientID;

DROP VIEW IF EXISTS `patient_summary`;
CREATE VIEW `patient_summary` AS
SELECT
  p.id                    AS PatientID,
  p.name                  AS PatientName,
  p.gender                AS Gender,
  MAX(a.time_slot)        AS LatestAppointment,
  MAX(diag.DiagnosisTime) AS LatestDiagnosis,
  MAX(l.TestTime)         AS LatestLabTest,
  MAX(r.TestTime)         AS LatestRadTest
FROM patients p
LEFT JOIN appointments a ON p.id = a.patient_uuid /*示例:如需 UUID 关联请调整*/
LEFT JOIN diagnoses diag ON p.id = diag.PatientID
LEFT JOIN labtests l   ON p.id = l.PatientID
LEFT JOIN radiologytests r ON p.id = r.PatientID
GROUP BY p.id,p.name,p.gender;

SET FOREIGN_KEY_CHECKS = 1;

你可能感兴趣的:(数据库,sql)