yxh
6 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/RoomController.java
@@ -10,6 +10,7 @@
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;
@@ -29,11 +30,16 @@
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.*;
@@ -41,6 +47,7 @@
@RestController
@RequestMapping("/clinic/room")
@Validated
@Slf4j
public class RoomController {
    @Resource
@@ -92,6 +99,258 @@
        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")
@@ -166,6 +425,10 @@
        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);
    }
@@ -200,5 +463,29 @@
        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);
    }
}