| | |
| | | import javax.validation.Valid; |
| | | |
| | | import cn.hutool.extra.servlet.ServletUtil; |
| | | import cn.lihu.jh.module.ecg.utils.IpUtils; |
| | | import cn.lihu.jh.module.ecg.controller.admin.room.vo.*; |
| | | import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO; |
| | | import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO; |
| | |
| | | import cn.lihu.jh.framework.common.pojo.PageParam; |
| | | import cn.lihu.jh.framework.common.pojo.PageResult; |
| | | import cn.lihu.jh.framework.common.pojo.CommonResult; |
| | | import cn.lihu.jh.framework.common.exception.ErrorCode; |
| | | import cn.lihu.jh.framework.common.exception.enums.GlobalErrorCodeConstants; |
| | | import cn.lihu.jh.framework.common.util.object.BeanUtils; |
| | | import cn.lihu.jh.framework.excel.core.util.ExcelUtils; |
| | | import cn.lihu.jh.framework.apilog.core.annotation.ApiAccessLog; |
| | | import cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | |
| | | import static cn.lihu.jh.framework.common.pojo.CommonResult.success; |
| | | import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception; |
| | | import static cn.lihu.jh.framework.apilog.core.enums.OperateTypeEnum.*; |
| | | |
| | | |
| | |
| | | @RestController |
| | | @RequestMapping("/clinic/room") |
| | | @Validated |
| | | @Slf4j |
| | | public class RoomController { |
| | | |
| | | @Resource |
| | |
| | | String reqIp = ServletUtil.getClientIP(request); |
| | | RoomProfile roomProfile = roomService.getRoomByIP(reqIp, roomId); |
| | | return success(BeanUtils.toBean(roomProfile, RoomProfileRespVO.class)); |
| | | } |
| | | |
| | | /* ==================== 选择工位模式(需求:诊室工位选择模式 0/1/2/3) ==================== */ |
| | | |
| | | /** |
| | | * 按本机IP匹配工位(模式 1/3 用)。 |
| | | * |
| | | * <p>只读接口: |
| | | * <ul> |
| | | * <li>把后端实际识别到的来访IP一起返回,便于排查"前置代理未透传 X-Forwarded-For";</li> |
| | | * <li>候选工位已按「本人已占用 → 空闲 → 被他人占用」排好序,前端取第一个可用的自动入座;</li> |
| | | * <li>本接口不改任何状态,入座仍走原有的 bedDoctorOn 流程。</li> |
| | | * </ul> |
| | | * 需要登录(未登录时返回空结果),但不做菜单权限校验:能登录就能查自己的工位。</p> |
| | | */ |
| | | @GetMapping("/get-ip-bed") |
| | | @Operation(summary = "按本机IP匹配工位(选择工位模式 1/3)") |
| | | public CommonResult<RoomSelectBedRespVO> getIpBed(HttpServletRequest request) { |
| | | // 规范化:去括号/区域号、统一小写、IPv4-mapped IPv6 还原成 IPv4(支持 IPv4 与 IPv6) |
| | | String clientIp = IpUtils.normalize(ServletUtil.getClientIP(request)); |
| | | Long docId = SecurityFrameworkUtils.getLoginUserId(); |
| | | RoomSelectBedRespVO resp = new RoomSelectBedRespVO(); |
| | | resp.setClientIp(clientIp); |
| | | resp.setIpValid(IpUtils.isBindableIp(clientIp)); |
| | | resp.setIpLoopback(IpUtils.isLoopback(clientIp)); |
| | | resp.setIpv6(IpUtils.isIpv6Address(clientIp)); |
| | | List<RoomSelectBedRespVO.RoomSelectBedItem> beds = roomService.matchBedsByIp(clientIp, docId); |
| | | resp.setBeds(beds); |
| | | resp.setIpBound(!beds.isEmpty()); |
| | | // 有"本人已占用"或"空闲"的工位,就能自动入座 |
| | | resp.setMatched(beds.stream() |
| | | .anyMatch(b -> Boolean.TRUE.equals(b.getMine()) || !Boolean.TRUE.equals(b.getOccupied()))); |
| | | log.info("[getIpBed][按IP匹配工位] clientIp={} docId={} ipValid={} loopback={} 候选数={} 可自动入座={}", |
| | | clientIp, docId, resp.getIpValid(), resp.getIpLoopback(), beds.size(), resp.getMatched()); |
| | | return success(resp); |
| | | } |
| | | |
| | | /** |
| | | * 把本机IP写入指定工位(模式 2/3 用)。 |
| | | * |
| | | * <p>只更新 {@code clinic_room.ip} 一个字段,不走 {@code clinic:room:update}(否则为了选工位还得给医生开诊室管理权限), |
| | | * 也不会碰 status/doc_id/check_types 等其它字段。 |
| | | * 若该IP已绑在别的工位上,默认返回冲突工位让前端确认;确认后带 {@code force=true} 再调一次即覆盖。</p> |
| | | */ |
| | | @PostMapping("/bind-ip") |
| | | @Operation(summary = "把本机IP绑定到工位(选择工位模式 2/3)") |
| | | public CommonResult<Map<String, Object>> bindIp(HttpServletRequest request, |
| | | @RequestParam("id") Integer id, |
| | | @RequestParam(value = "force", required = false, defaultValue = "false") Boolean force) { |
| | | Map<String, Object> data = new HashMap<>(); |
| | | String rawIp = ServletUtil.getClientIP(request); |
| | | String clientIp = IpUtils.normalize(rawIp); |
| | | data.put("clientIp", clientIp); |
| | | if (!IpUtils.isBindableIp(clientIp)) { |
| | | data.put("code", 400); |
| | | if (IpUtils.isLoopback(clientIp)) { |
| | | // 浏览器和服务在同一台机器上访问时必然出现:IPv4 是 127.0.0.1,IPv6 是 ::1 |
| | | data.put("msg", "无法绑定工位:检测到当前访问来自本机回环地址(" + clientIp |
| | | + ",即本机访问),请在医生实际使用的电脑上访问后再绑定"); |
| | | } else if (IpUtils.isUnspecified(clientIp)) { |
| | | data.put("msg", "无法绑定工位:当前识别到的地址是未指定地址(" + clientIp + ")"); |
| | | } else if (IpUtils.isLocalAddress(clientIp)) { |
| | | // 本次新增:现场最常见的"IP 不对"就是这种 —— 在服务器上(或远程桌面里)访问系统, |
| | | // 客户端本来就是服务器自己;若把它写进工位,该工位以后永远匹配不到医生电脑。 |
| | | data.put("msg", "无法绑定工位:识别到的是**服务器自身地址**(" + clientIp |
| | | + ")—— 说明当前是在服务器上访问,或经远程桌面/本机代理访问,这不是医生电脑的 IP。" |
| | | + "请在医生实际使用的电脑上、用服务器的 IPv4 地址访问后再绑定" |
| | | + "(可用「IP 诊断」查看后端到底把谁当成了客户端)"); |
| | | log.warn("[bindIp][识别到服务器自身地址,拒绝绑定] clientIp={} bedId={} 服务器网卡IPv4={}", |
| | | clientIp, id, IpUtils.localIpv4Addresses()); |
| | | } else if (IpUtils.isIpv6Address(clientIp)) { |
| | | // 本系统工位绑定统一采用 IPv4;客户端走 IPv6 时无法从代码"退化成"IPv4 |
| | | data.put("msg", "无法绑定工位:当前访问使用的是 IPv6 地址(" + clientIp |
| | | + "),本系统工位绑定仅采用 IPv4,请改用服务器的 IPv4 地址访问" |
| | | + "(或让服务只监听 IPv4:JVM 参数 -Djava.net.preferIPv4Stack=true)"); |
| | | } else { |
| | | data.put("msg", "无法绑定工位:未能识别到合法的 IPv4 来访地址(" + clientIp |
| | | + "),请检查是否通过代理访问、以及代理是否透传客户端地址(X-Forwarded-For)"); |
| | | } |
| | | log.warn("[bindIp][IP不可用,拒绝绑定] raw={} normalized={} ipv6={} bedId={}", |
| | | rawIp, clientIp, IpUtils.isIpv6Address(clientIp), id); |
| | | return success(data); |
| | | } |
| | | // 冲突检测:该IP已绑在别的工位 → 先让前端确认 |
| | | List<RoomDO> conflicts = roomService.listIpBindings(clientIp, id); |
| | | if (!conflicts.isEmpty() && !Boolean.TRUE.equals(force)) { |
| | | RoomDO c = conflicts.get(0); |
| | | data.put("code", 409); |
| | | data.put("conflict", true); |
| | | data.put("conflictBedId", c.getId()); |
| | | data.put("conflictRoomName", c.getRoomName()); |
| | | data.put("conflictBedNo", c.getBedNo()); |
| | | data.put("msg", "该IP已绑定「" + c.getRoomName() + " / " + c.getBedNo() + "」"); |
| | | return success(data); |
| | | } |
| | | Integer rows = roomService.bindIpToBed(id, clientIp); |
| | | data.put("code", rows != null && rows > 0 ? 200 : 500); |
| | | data.put("msg", rows != null && rows > 0 ? "已绑定本机IP到该工位" : "绑定失败(工位不存在?)"); |
| | | data.put("rows", rows); |
| | | log.info("[bindIp][绑定结果] raw={} clientIp={} bedId={} force={} rows={}", rawIp, clientIp, id, force, rows); |
| | | return success(data); |
| | | } |
| | | |
| | | /* ==================== 自动入座 / 我的工位(本次需求) ==================== */ |
| | | |
| | | /** |
| | | * 一键自动入座(选择工位模式 1/3 用)。 |
| | | * |
| | | * <p>为什么合并成一个接口:前端原来把「匹配 → 绑 IP → 入座」拆成三个请求自己拼, |
| | | * 任一步失败都只能回落"手选工位",现场表现就是"登录后没有自动入座"且看不出原因。 |
| | | * 现在一次调用完成,并返回**识别到的 IP** 与**未入座原因** |
| | | * ({@code ip_invalid / nomatch / multi / occupied / error}),排查一眼可辨。 |
| | | * |
| | | * <p>需要登录,但不做菜单权限校验:能登录就能坐自己的工位。 |
| | | */ |
| | | @PostMapping("/auto-seat") |
| | | @Operation(summary = "按本机IP自动匹配工位并自动入座(选择工位模式 1/3)") |
| | | public CommonResult<RoomSeatRespVO> autoSeat(HttpServletRequest request) { |
| | | String clientIp = IpUtils.normalize(ServletUtil.getClientIP(request)); |
| | | Long docId = SecurityFrameworkUtils.getLoginUserId(); |
| | | String docName = SecurityFrameworkUtils.getLoginUserNickname(); |
| | | RoomSeatRespVO resp = roomService.autoSeatByIp(clientIp, docId, docName); |
| | | log.info("[autoSeat][自动入座] clientIp={} docId={} seated={} reason={} 工位={}/{}", |
| | | clientIp, docId, resp.getSeated(), resp.getReason(), resp.getRoomName(), resp.getBedNo()); |
| | | return success(resp); |
| | | } |
| | | |
| | | /** |
| | | * 查询当前登录人**已入座**的工位(系统顶栏"当前工位"标签用)。 |
| | | * |
| | | * <p>未入座时返回 {@code seated=false},前端据此显示灰色「未入座」并可点击去选工位。 |
| | | */ |
| | | @GetMapping("/my-seat") |
| | | @Operation(summary = "查询当前登录人已入座的工位(系统顶栏展示)") |
| | | public CommonResult<RoomSeatRespVO> mySeat() { |
| | | Long docId = SecurityFrameworkUtils.getLoginUserId(); |
| | | RoomSeatRespVO resp = roomService.getMySeat(docId); |
| | | log.info("[mySeat][查询我的工位] docId={} seated={} 工位={}/{}", |
| | | docId, resp.getSeated(), resp.getRoomName(), resp.getBedNo()); |
| | | return success(resp); |
| | | } |
| | | |
| | | /** |
| | | * 【本需求·一人多工位】查询当前登录人占据的**全部**工位(节假日值班多岗位)。 |
| | | * |
| | | * <p>返回「医生在座 / 医生暂停」的工位列表,按 roomId + bedNo 升序;未占工位返回空数组。 |
| | | * 前端用它渲染顶栏「我的工位(N 个)」并在工位之间切换。 |
| | | */ |
| | | @GetMapping("/my-seats") |
| | | @Operation(summary = "查询当前登录人已入座的全部工位(一人多岗位)") |
| | | public CommonResult<List<RoomSeatRespVO>> mySeats() { |
| | | Long docId = SecurityFrameworkUtils.getLoginUserId(); |
| | | List<RoomSeatRespVO> seats = roomService.getMySeats(docId); |
| | | log.info("[mySeats][查询我的全部工位] docId={} 数量={}", docId, null == seats ? 0 : seats.size()); |
| | | return success(seats); |
| | | } |
| | | |
| | | /** |
| | | * 【本需求·接班自助接管】把工位从"别人占用"改为"当前登录人占用"。 |
| | | * |
| | | * <p>场景:上一班医生下班未点「离座」,下一班医生在同一台电脑/同一工位接班。 |
| | | * 原占用人会被强制离座(其页面 5s 轮询会提示「请重新就坐」并转只读)。 |
| | | * 权限沿用医生工作台已有的 {@code ecg:doctor:task},无需新增权限点。 |
| | | */ |
| | | @PostMapping("/take-over-bed") |
| | | @Operation(summary = "接管工位(接班:把他人占用的工位转给我)") |
| | | @PreAuthorize("@ss.hasPermission('ecg:doctor:task')") |
| | | public CommonResult<Boolean> takeOverBed(@RequestParam("roomId") Long roomId, |
| | | @RequestParam("bedNo") String bedNo) { |
| | | roomService.takeOverBed(roomId, bedNo); |
| | | return success(true); |
| | | } |
| | | |
| | | /** |
| | | * 客户端 IP 识别诊断(现场排查"工位绑定的 IP 不对"用)。 |
| | | * |
| | | * <p>一次把所有相关线索摊开:各个代理头、TCP 直连地址、规范化结果、是否回环/IPv6、 |
| | | * **是否是服务器自身地址**、以及服务器所有 IPv4 网卡地址。界面上的「IP 诊断」按钮就调它。 |
| | | * |
| | | * <p>典型结论: |
| | | * <ul> |
| | | * <li>取到的是服务器自身地址 → 当前是在服务器上/远程桌面里访问,或代理把客户端地址覆盖成了服务器地址;</li> |
| | | * <li>取到 127.x / ::1 → 浏览器与服务在同一台机器;</li> |
| | | * <li>取到 IPv6 → 客户端走 IPv6(本系统工位绑定只用 IPv4);</li> |
| | | * <li>{@code X-Forwarded-For} 为空且直连地址=网关 → 前面的代理没透传客户端地址。</li> |
| | | * </ul> |
| | | */ |
| | | @GetMapping("/ip-debug") |
| | | @Operation(summary = "客户端IP识别诊断(排查工位绑定IP不对)") |
| | | public CommonResult<Map<String, Object>> ipDebug(HttpServletRequest request) { |
| | | Map<String, Object> data = new LinkedHashMap<>(); |
| | | String xff = request.getHeader("X-Forwarded-For"); |
| | | String realIp = request.getHeader("X-Real-IP"); |
| | | String remoteAddr = request.getRemoteAddr(); |
| | | String raw = ServletUtil.getClientIP(request); |
| | | String clientIp = IpUtils.normalize(raw); |
| | | |
| | | data.put("访问Host", request.getHeader("Host")); |
| | | data.put("X-Forwarded-For", xff); |
| | | data.put("X-Real-IP", realIp); |
| | | data.put("Proxy-Client-IP", request.getHeader("Proxy-Client-IP")); |
| | | data.put("WL-Proxy-Client-IP", request.getHeader("WL-Proxy-Client-IP")); |
| | | data.put("HTTP_CLIENT_IP", request.getHeader("HTTP_CLIENT_IP")); |
| | | data.put("直连地址(remoteAddr)", remoteAddr); |
| | | data.put("取到的原始值", raw); |
| | | data.put("规范化后(工位按此匹配)", clientIp); |
| | | data.put("可用于工位绑定", IpUtils.isBindableIp(clientIp)); |
| | | data.put("是回环(浏览器与服务同机)", IpUtils.isLoopback(clientIp)); |
| | | data.put("是IPv6", IpUtils.isIpv6Address(clientIp)); |
| | | data.put("是服务器自身地址", IpUtils.isLocalAddress(clientIp)); |
| | | data.put("服务器网卡IPv4", IpUtils.localIpv4Addresses()); |
| | | |
| | | String verdict; |
| | | if (IpUtils.isLoopback(clientIp)) { |
| | | verdict = "客户端就是服务器本机(回环 " + clientIp + "):属【浏览器与服务在同一台机器】的正常现象," |
| | | + "不能作为工位标识,请在医生电脑上访问。"; |
| | | } else if (IpUtils.isLocalAddress(clientIp)) { |
| | | verdict = "⚠ 识别到的是【服务器自身地址】(" + clientIp + "):当前是在服务器上/远程桌面里访问," |
| | | + "或前置代理把客户端地址覆盖成了服务器地址 → 不能作为工位标识。" |
| | | + "请在医生电脑上、用服务器的 IPv4 地址访问;若确认是在医生电脑上访问,请检查代理是否透传 " |
| | | + "X-Forwarded-For(nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;)。"; |
| | | } else if (IpUtils.isIpv6Address(clientIp)) { |
| | | verdict = "客户端走的是 IPv6(" + clientIp + "):本系统工位绑定只采用 IPv4," |
| | | + "请让服务只监听 IPv4(JVM: -Djava.net.preferIPv4Stack=true)或改用 IPv4 地址访问。"; |
| | | } else if (IpUtils.isBindableIp(clientIp)) { |
| | | verdict = "识别正常,可用于工位绑定:" + clientIp; |
| | | } else { |
| | | verdict = "未能识别到可用的 IPv4 客户端地址(原始值:" + raw + ",直连地址:" + remoteAddr |
| | | + "):请检查代理是否透传客户端地址(X-Forwarded-For)。"; |
| | | } |
| | | data.put("诊断结论", verdict); |
| | | |
| | | log.info("[ipDebug][客户端IP诊断] Host={} XFF={} X-Real-IP={} remoteAddr={} → clientIp={} 服务器地址={}", |
| | | request.getHeader("Host"), xff, realIp, remoteAddr, clientIp, IpUtils.isLocalAddress(clientIp)); |
| | | return success(data); |
| | | } |
| | | |
| | | /** |
| | | * 工位状态诊断(排查"入座报状态异常 / 明明空闲却坐不上")。 |
| | | * |
| | | * <p>把库里**原始行**摊开:`id / room_id / room_name / bed_no(含长度与 HEX,用于发现结尾空格、全角) / |
| | | * ip / status / doc_id / doc_name / op_type / deleted / update_time`,并给出一句结论。 |
| | | * |
| | | * <p>用法:`GET /clinic/room/bed-state?id=3` 或 `?roomId=118&bedNo=C1`。 |
| | | */ |
| | | @GetMapping("/bed-state") |
| | | @Operation(summary = "工位状态诊断(排查入座报状态异常)") |
| | | public CommonResult<Map<String, Object>> bedState( |
| | | @RequestParam(value = "id", required = false) Integer id, |
| | | @RequestParam(value = "roomId", required = false) Long roomId, |
| | | @RequestParam(value = "bedNo", required = false) String bedNo) { |
| | | return success(roomService.diagnoseBed(id, roomId, bedNo)); |
| | | } |
| | | |
| | | @GetMapping("/page") |
| | |
| | | List<RoomDO> list = roomService.simpleRoomList(); |
| | | List<RoomRespVO> list2 = BeanUtils.toBean(list, RoomRespVO.class); |
| | | |
| | | // 显式标注「是否已被医生占用」:选工位页据此显示「已入座:张三」并**禁用入座按钮**, |
| | | // 不依赖 BeanUtils 是否把 docId 复制过来(双保险;本次现场就有"看不出来谁占了工位"的反馈)。 |
| | | list2.forEach(roomRespVO -> roomRespVO.setOccupied(null != roomRespVO.getDocId())); |
| | | |
| | | Map<Long, List<RoomRespVO>> map = list2.stream().collect(Collectors.groupingBy(RoomRespVO::getRoomId)); |
| | | return success(map); |
| | | } |
| | |
| | | return success(0); |
| | | } |
| | | |
| | | /** |
| | | * 【本次需求】管理员强制清空某个工位(不要求是占用者本人)。 |
| | | * <p> |
| | | * 使用场景:医生下班忘记「离座」,或换人使用同一工位。 |
| | | * <p> |
| | | * 与医生自己的「离座」接口({@code /ecg/doctor/bed-doctor-off})的区别: |
| | | * · 该接口只能清**自己**占用的工位(SQL 带 {@code doc_id = 当前用户}); |
| | | * · 本接口面向管理端,可以清**任何人**占用的工位(仅要求该工位当前是「医生在岗 / 暂停」)。 |
| | | * <p> |
| | | * 工位空闲时会返回「当前工位状态不允许该操作」——这是预期行为。 |
| | | */ |
| | | @PostMapping("/force-clear-bed") |
| | | @Operation(summary = "管理员强制清空工位", description = "医生下班忘离座 / 换人使用同一工位时使用") |
| | | @PreAuthorize("@ss.hasPermission('clinic:room:update')") |
| | | public CommonResult<Boolean> forceClearBed(@RequestParam("roomId") Long roomId, |
| | | @RequestParam("bedNo") String bedNo) { |
| | | ErrorCode ec = queueService.startBedDoctorOffByAdmin(roomId, bedNo); |
| | | if (!GlobalErrorCodeConstants.SUCCESS.getCode().equals(ec.getCode())) { |
| | | log.warn("[forceClearBed] 清空工位失败 roomId={} bedNo={} code={} msg={}", |
| | | roomId, bedNo, ec.getCode(), ec.getMsg()); |
| | | throw exception(ec); |
| | | } |
| | | return success(true); |
| | | } |
| | | |
| | | } |