# 药品信息管理功能设计文档 ## 1. 功能概述 药品信息管理功能旨在为医生提供一个维护和管理常用药品信息的平台。该功能允许医生查看、添加、编辑和删除药品信息,包括药品名称、拼音首字母(用于快速搜索)以及条形码等基本信息。这些药品信息可用于处方开具、药品搜索等场景。 ## 2. 数据库设计 ### 2.1 药品信息表 (t_medicine) 根据项目数据库表设计规范(参见 `docs/OLD/DevRule/07-数据库规范.md`),创建药品信息表: | 字段名 | 类型 | 描述 | |--------|------|------| | id | BIGINT(20) | 主键ID,使用雪花算法(MyBatis-Plus `ASSIGN_ID`) | | name | VARCHAR(100) | 药品名称 | | pinyin_first_letters | VARCHAR(50) | 拼音首字母,用于快速搜索 | | barcode | VARCHAR(50) | 条形码(可选) | | version | INT(11) | 版本号(乐观锁) | | create_user | BIGINT(20) | 创建者ID | | create_time | DATETIME | 创建时间,默认值CURRENT_TIMESTAMP | | update_user | BIGINT(20) | 更新者ID | | update_time | DATETIME | 更新时间,默认值CURRENT_TIMESTAMP,更新时自动设置为当前时间 | | remark | VARCHAR(255) | 备注 | 遵循的数据库规范要点: - 使用 `BIGINT` 主键并通过雪花算法生成(MyBatis-Plus `ASSIGN_ID`)。 - 公共字段(`version`、`create_user`、`create_time`、`update_user`、`update_time`)由 `BaseEntity` 与 `CustomMetaObjectHandler` 自动填充。 - 根据需要添加索引以提高查询效率。 - 数据迁移建议使用 Flyway/Liquibase(脚本命名遵循 `V{version}__{description}.sql`)。 示例建表(参考): ```sql CREATE TABLE `t_medicine` ( `id` bigint(20) NOT NULL COMMENT '主键ID', `name` varchar(100) NOT NULL COMMENT '药品名称', `pinyin_first_letters` varchar(50) NOT NULL COMMENT '拼音首字母,用于快速搜索', `barcode` varchar(50) DEFAULT NULL COMMENT '条形码', `version` int(11) DEFAULT '0' COMMENT '版本号(乐观锁)', `create_user` bigint(20) DEFAULT NULL COMMENT '创建者ID', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_user` bigint(20) DEFAULT NULL COMMENT '更新者ID', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`), UNIQUE KEY `uk_name` (`name`), KEY `idx_pinyin_first_letters` (`pinyin_first_letters`), KEY `idx_barcode` (`barcode`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='药品信息表'; ``` 备注:对外响应的 `id` 建议以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。 ## 3. 实体类设计 ### 3.1 PO实体类 (Medicine.java) ```java package work.baiyun.chronicdiseaseapp.model.po; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @Schema(description = "药品信息表") @TableName("t_medicine") @Data @EqualsAndHashCode(callSuper = false) public class Medicine extends BaseEntity { @Schema(description = "药品名称") @TableField("name") private String name; @Schema(description = "拼音首字母,用于快速搜索") @TableField("pinyin_first_letters") private String pinyinFirstLetters; @Schema(description = "条形码") @TableField("barcode") private String barcode; } ``` ### 3.2 VO对象 #### 3.2.1 请求对象 ```java // CreateMedicineRequest.java package work.baiyun.chronicdiseaseapp.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @Schema(description = "创建药品请求") @Data public class CreateMedicineRequest { @Schema(description = "药品名称", required = true) @NotBlank(message = "药品名称不能为空") @Size(max = 100, message = "药品名称长度不能超过100个字符") private String name; @Schema(description = "拼音首字母,用于快速搜索", required = true) @NotBlank(message = "拼音首字母不能为空") @Size(max = 50, message = "拼音首字母长度不能超过50个字符") private String pinyinFirstLetters; @Schema(description = "条形码") @Size(max = 50, message = "条形码长度不能超过50个字符") private String barcode; } // UpdateMedicineRequest.java package work.baiyun.chronicdiseaseapp.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @Schema(description = "更新药品请求") @Data public class UpdateMedicineRequest { @Schema(description = "药品ID", required = true) @NotNull(message = "药品ID不能为空") private Long id; @Schema(description = "药品名称", required = true) @NotBlank(message = "药品名称不能为空") @Size(max = 100, message = "药品名称长度不能超过100个字符") private String name; @Schema(description = "拼音首字母,用于快速搜索", required = true) @NotBlank(message = "拼音首字母不能为空") @Size(max = 50, message = "拼音首字母长度不能超过50个字符") private String pinyinFirstLetters; @Schema(description = "条形码") @Size(max = 50, message = "条形码长度不能超过50个字符") private String barcode; } // MedicineQueryRequest.java package work.baiyun.chronicdiseaseapp.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @Schema(description = "药品查询请求") @Data @EqualsAndHashCode(callSuper = true) public class MedicineQueryRequest extends BaseQueryRequest { @Schema(description = "搜索关键字(药品名称或拼音首字母)") private String keyword; } ``` #### 3.2.2 响应对象 ```java // MedicineResponse.java package work.baiyun.chronicdiseaseapp.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Schema(description = "药品信息响应") @Data public class MedicineResponse { @Schema(description = "药品ID") private String id; @Schema(description = "药品名称") private String name; @Schema(description = "拼音首字母,用于快速搜索") private String pinyinFirstLetters; @Schema(description = "条形码") private String barcode; @Schema(description = "创建时间") private java.time.LocalDateTime createTime; } // MedicinePageResponse.java package work.baiyun.chronicdiseaseapp.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; @Schema(description = "药品信息分页响应") @Data public class MedicinePageResponse { @Schema(description = "数据列表") private List records; @Schema(description = "总数") private long total; @Schema(description = "每页大小") private long size; @Schema(description = "当前页码") private long current; @Schema(description = "总页数") private long pages; } ``` ## 4. Mapper接口设计 ```java // MedicineMapper.java package work.baiyun.chronicdiseaseapp.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import work.baiyun.chronicdiseaseapp.model.po.Medicine; import org.apache.ibatis.annotations.Mapper; @Mapper public interface MedicineMapper extends BaseMapper { } ``` ## 5. Service层设计 ### 5.1 接口定义 ```java // MedicineService.java package work.baiyun.chronicdiseaseapp.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import work.baiyun.chronicdiseaseapp.model.vo.MedicineQueryRequest; import work.baiyun.chronicdiseaseapp.model.vo.MedicineResponse; import work.baiyun.chronicdiseaseapp.model.vo.CreateMedicineRequest; import work.baiyun.chronicdiseaseapp.model.vo.UpdateMedicineRequest; public interface MedicineService { /** * 创建药品信息 */ void createMedicine(CreateMedicineRequest request); /** * 更新药品信息 */ void updateMedicine(UpdateMedicineRequest request); /** * 分页查询药品信息 */ Page listMedicines(MedicineQueryRequest request); /** * 根据ID删除药品信息 */ void deleteMedicine(Long id); /** * 根据ID获取药品详情 */ MedicineResponse getMedicineById(Long id); } ``` ### 5.2 实现类 ```java // MedicineServiceImpl.java package work.baiyun.chronicdiseaseapp.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import work.baiyun.chronicdiseaseapp.mapper.MedicineMapper; import work.baiyun.chronicdiseaseapp.model.po.Medicine; import work.baiyun.chronicdiseaseapp.model.vo.MedicineQueryRequest; import work.baiyun.chronicdiseaseapp.model.vo.MedicineResponse; import work.baiyun.chronicdiseaseapp.model.vo.CreateMedicineRequest; import work.baiyun.chronicdiseaseapp.model.vo.UpdateMedicineRequest; import work.baiyun.chronicdiseaseapp.service.MedicineService; import work.baiyun.chronicdiseaseapp.util.SecurityUtils; import java.util.List; import java.util.stream.Collectors; @Service public class MedicineServiceImpl implements MedicineService { @Autowired private MedicineMapper medicineMapper; @Override public void createMedicine(CreateMedicineRequest request) { // 检查药品名称是否已存在 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(Medicine::getName, request.getName()); if (medicineMapper.selectCount(queryWrapper) > 0) { throw new work.baiyun.chronicdiseaseapp.exception.CustomException( work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(), "药品名称已存在"); } Medicine medicine = new Medicine(); BeanUtils.copyProperties(request, medicine); medicine.setPinyinFirstLetters(request.getPinyinFirstLetters().toLowerCase()); medicineMapper.insert(medicine); } @Override public void updateMedicine(UpdateMedicineRequest request) { Medicine medicine = medicineMapper.selectById(request.getId()); if (medicine == null) { throw new work.baiyun.chronicdiseaseapp.exception.CustomException( work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_NOT_FOUND.getCode(), "药品信息不存在"); } // 检查药品名称是否已存在(排除自己) LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(Medicine::getName, request.getName()) .ne(Medicine::getId, request.getId()); if (medicineMapper.selectCount(queryWrapper) > 0) { throw new work.baiyun.chronicdiseaseapp.exception.CustomException( work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(), "药品名称已存在"); } BeanUtils.copyProperties(request, medicine); medicine.setPinyinFirstLetters(request.getPinyinFirstLetters().toLowerCase()); medicineMapper.updateById(medicine); } @Override public Page listMedicines(MedicineQueryRequest request) { Page page = new Page<>(request.getPageNum(), request.getPageSize()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); // 根据关键字搜索(药品名称或拼音首字母) if (request.getKeyword() != null && !request.getKeyword().trim().isEmpty()) { String keyword = request.getKeyword().trim().toLowerCase(); wrapper.and(w -> w.like(Medicine::getName, keyword) .or() .like(Medicine::getPinyinFirstLetters, keyword)); } // 时间范围筛选 wrapper.ge(request.getStartTime() != null, Medicine::getCreateTime, request.getStartTime()) .le(request.getEndTime() != null, Medicine::getCreateTime, request.getEndTime()) .orderByDesc(Medicine::getCreateTime); Page result = medicineMapper.selectPage(page, wrapper); List responses = result.getRecords().stream().map(r -> { MedicineResponse resp = new MedicineResponse(); BeanUtils.copyProperties(r, resp); resp.setId(r.getId().toString()); return resp; }).collect(Collectors.toList()); Page responsePage = new Page<>(); responsePage.setRecords(responses); responsePage.setCurrent(result.getCurrent()); responsePage.setSize(result.getSize()); responsePage.setTotal(result.getTotal()); responsePage.setPages(result.getPages()); return responsePage; } @Override public void deleteMedicine(Long id) { Medicine medicine = medicineMapper.selectById(id); if (medicine == null) { throw new work.baiyun.chronicdiseaseapp.exception.CustomException( work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_NOT_FOUND.getCode(), "药品信息不存在"); } medicineMapper.deleteById(id); } @Override public MedicineResponse getMedicineById(Long id) { Medicine medicine = medicineMapper.selectById(id); if (medicine == null) { throw new work.baiyun.chronicdiseaseapp.exception.CustomException( work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_NOT_FOUND.getCode(), "药品信息不存在"); } MedicineResponse response = new MedicineResponse(); BeanUtils.copyProperties(medicine, response); response.setId(medicine.getId().toString()); return response; } } ``` ## 6. Controller层设计 ```java // MedicineController.java package work.baiyun.chronicdiseaseapp.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import work.baiyun.chronicdiseaseapp.common.R; import work.baiyun.chronicdiseaseapp.model.vo.MedicineQueryRequest; import work.baiyun.chronicdiseaseapp.model.vo.MedicineResponse; import work.baiyun.chronicdiseaseapp.model.vo.CreateMedicineRequest; import work.baiyun.chronicdiseaseapp.model.vo.UpdateMedicineRequest; import work.baiyun.chronicdiseaseapp.service.MedicineService; import work.baiyun.chronicdiseaseapp.enums.ErrorCode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @RestController @RequestMapping("/medicine") @Tag(name = "药品信息管理", description = "药品信息管理相关接口") public class MedicineController { private static final Logger logger = LoggerFactory.getLogger(MedicineController.class); @Autowired private MedicineService medicineService; @Operation(summary = "创建药品信息", description = "创建新的药品信息") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "药品信息创建成功", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))), @ApiResponse(responseCode = "500", description = "服务器内部错误", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))) }) @PostMapping(path = "/create", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public R create(@RequestBody CreateMedicineRequest req) { try { medicineService.createMedicine(req); return R.success(200, "药品信息创建成功"); } catch (Exception e) { logger.error("create medicine failed", e); return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage()); } } @Operation(summary = "更新药品信息", description = "更新药品信息") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "药品信息更新成功", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))), @ApiResponse(responseCode = "500", description = "服务器内部错误", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))) }) @PutMapping(path = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public R update(@PathVariable Long id, @RequestBody UpdateMedicineRequest req) { try { // 将路径ID赋值到请求对象,保证一致性 req.setId(id); medicineService.updateMedicine(req); return R.success(200, "药品信息更新成功"); } catch (Exception e) { logger.error("update medicine failed", e); return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage()); } } @Operation(summary = "分页查询药品信息", description = "根据关键字和分页参数查询药品信息") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "成功查询药品信息列表", content = @Content(mediaType = "application/json", schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.MedicinePageResponse.class))), @ApiResponse(responseCode = "500", description = "服务器内部错误", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))) }) @PostMapping(path = "/list", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public R list(@RequestBody MedicineQueryRequest req) { try { Page page = medicineService.listMedicines(req); work.baiyun.chronicdiseaseapp.model.vo.MedicinePageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.MedicinePageResponse(); vo.setRecords(page.getRecords()); vo.setTotal(page.getTotal()); vo.setSize(page.getSize()); vo.setCurrent(page.getCurrent()); vo.setPages(page.getPages()); return R.success(200, "ok", vo); } catch (Exception e) { logger.error("list medicines failed", e); return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage()); } } @Operation(summary = "删除药品信息", description = "根据ID删除药品信息") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "药品信息删除成功", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))), @ApiResponse(responseCode = "500", description = "服务器内部错误", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))) }) @DeleteMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) public R delete(@PathVariable Long id) { try { medicineService.deleteMedicine(id); return R.success(200, "药品信息删除成功"); } catch (Exception e) { logger.error("delete medicine failed", e); return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage()); } } @Operation(summary = "获取药品详情", description = "根据ID获取药品详细信息") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "成功获取药品信息", content = @Content(mediaType = "application/json", schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.MedicineResponse.class))), @ApiResponse(responseCode = "500", description = "服务器内部错误", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))) }) @GetMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) public R get(@PathVariable Long id) { try { MedicineResponse medicine = medicineService.getMedicineById(id); return R.success(200, "ok", medicine); } catch (Exception e) { logger.error("get medicine failed", e); return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage()); } } } ``` ## 7. 错误码补充 在 ErrorCode 枚举中可能需要添加以下错误码: ```java // 在 ErrorCode.java 中添加 MEDICINE_NOT_FOUND(7000, "药品信息不存在"), MEDICINE_NAME_EXISTS(7001, "药品名称已存在"); ``` ## 8. 接口调用示例 ### 8.1 添加药品信息 ```http POST /medicine/create Content-Type: application/json { "name": "阿司匹林", "pinyinFirstLetters": "asp", "barcode": "6920224840072" } ``` ### 8.2 更新药品信息 ```http PUT /medicine/1 Content-Type: application/json { "id": 1, "name": "阿司匹林肠溶片", "pinyinFirstLetters": "asplr", "barcode": "6920224840073" } ``` ### 8.3 查询药品信息 ```http POST /medicine/list Content-Type: application/json { "pageNum": 1, "pageSize": 10, "keyword": "asp" } ``` ### 8.4 删除药品信息 ```http DELETE /medicine/1 ``` ### 8.5 获取药品详情 ```http GET /medicine/1 ``` ## 9. 权限设计 1. **医生**: - 可以查看药品信息列表 - 可以添加新的药品信息 - 可以编辑现有的药品信息 - 可以删除药品信息 2. **系统管理员**: - 具有医生的所有权限 - 可以管理所有药品信息 ## 10. 安全考虑 本项目安全实现应遵循 `docs/OLD/DevRule/08-安全规范.md` 中的已有约定,以下为与药品信息管理功能相关的要点(需在实现时落地): 1. **认证与授权**: - 使用 Token(优先 `Authorization: Bearer `,兼容 `X-Token` / `token`)并通过 `AuthInterceptor` 校验,将 `currentUserId`/`currentUserRole` 注入请求上下文。 - 仅允许具有医生或系统管理员角色的用户访问药品管理功能。 2. **输入校验**: - 使用 Bean Validation 注解(`@NotBlank`、`@Size` 等)对请求参数进行校验。 - 对药品名称进行唯一性校验,防止重复添加。 3. **异常与统一错误响应**: - 使用全局异常处理器(`@RestControllerAdvice`/`CustomExceptionHandler`)统一映射 `CustomException`、校验异常、未知异常到 `R.fail(code, message)`,并返回合适的 HTTP 状态码(如 400/403/404/500)。 - 业务异常(如 `MEDICINE_NOT_FOUND` / `MEDICINE_NAME_EXISTS`)应使用明确错误码并在 `ErrorCode` 中定义。 4. **日志与敏感信息**: - 在日志中避免记录完整 Token 或敏感字段;若需记录,脱敏或仅记录前 8 位。 - 记录关键审计事件:创建、更新、删除等操作的用户 ID 与时间戳。 5. **SQL 注入与 ORM 使用**: - 使用 MyBatis-Plus 提供的预编译与条件构造器,禁止使用字符串拼接构建 SQL。 6. **会话安全与 Token 管理**: - Token 存储与过期策略遵循全局规范(存 `t_user_token`,有效期 72 小时,低于阈值自动延长)。 7. **部署/传输**: - 建议生产环境使用 HTTPS,证书放置在 `classpath:cert/` 并在启动时加载。 8. **最小权限原则**: - 服务端对每个接口、数据读取与修改操作都进行基于角色的授权校验,不信任前端传入的角色或用户 ID。 9. **审计与监控**: - 对认证失败、权限拒绝、异常错误等关键安全事件进行集中日志与告警(便于安全分析)。