diff --git a/server/.env.wecom-rpa.example b/server/.env.wecom-rpa.example new file mode 100644 index 000000000..b598b584a --- /dev/null +++ b/server/.env.wecom-rpa.example @@ -0,0 +1,25 @@ +; 企业微信客服(桌面软件 / 手机远程控制台)对接配置 +; 把下面的 [WECOM_RPA] 段追加到 server/.env 末尾使用(勿提交真实 .env)。 +; php-fpm 每个请求都会重新读取 .env,保存即生效;常驻进程(如 php think run)需重启。 +; +; 背景:员工在自己电脑上用客服桌面软件登录 ZYT(终端 7),令牌交给客服后台, +; 由客服后台服务器代查患者、订单。后台令牌默认绑定登录 IP,服务器出口 IP 与员工 +; 电脑不同,会被判为「ip地址发生变化,请重新登录」,桌面端表现为「ZYT 患者查询 +; 登录已过期」,重新验证后下一次查询仍然失败。 +; +; SERVER_IPS:客服后台服务器的公网出口 IP,多个用英文逗号或空格分隔,逐个精确匹配 +; (IPv4/IPv6 字面量,不支持网段)。以 ZYT 看到的来源 IP(REMOTE_ADDR)为准, +; 可在 ZYT 访问日志里核对客服后台请求的来源地址;出口 IP 变更后须同步修改。 +; 只有终端 7(桌面)/ 9(手机控制台)的令牌、且请求来自这些 IP 时才跳过登录 IP +; 绑定;网页后台、APP 等其他终端令牌不受影响,菜单权限照常校验。 +; 留空或不配置 = 保持原有 IP 绑定行为。 +; +; 生产环境客服后台出口 IP(2026-09 核对):123.14.175.249 +; 即生产 .env 中填写:SERVER_IPS = 123.14.175.249 +; +; 注意:本文件和 .env 都按 INI 解析,注释请用分号开头;以 # 开头且带等号的行 +; 会被当成配置键读入。也可以不写分区,在所有 [分区] 之前写顶层键 +; WECOM_RPA_SERVER_IPS,效果相同。 + +[WECOM_RPA] +SERVER_IPS = diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index 2b72acd8c..7c1f67dae 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -21,6 +21,7 @@ use app\common\service\pharmacy\PharmacyUploadPermissionAlias; use app\common\service\qywx\QywxPromotionOperatorAccess; use app\common\{ cache\AdminAuthCache, + enum\AdminTerminalEnum, service\JsonService }; use think\helper\Str; @@ -47,7 +48,13 @@ class AuthMiddleware return $next($request); } - if ($request->adminInfo['login_ip'] != request()->ip()) { + // 登录 IP 绑定;客服后台服务器代查患者/订单的例外见 isTrustedRpaServerRequest + if ($request->adminInfo['login_ip'] != request()->ip() + && !self::isTrustedRpaServerRequest( + (array) $request->adminInfo, + request()->ip(), + env('wecom_rpa.server_ips', '') + )) { return JsonService::fail('ip地址发生变化,请重新登录', [], -1); } @@ -122,6 +129,29 @@ class AuthMiddleware }, $data); } + /** + * 企业微信客服桌面软件(7)/ 手机控制台(9)的令牌会交给客服后台,由客服后台服务器 + * 代查患者、订单;服务器出口 IP 与员工电脑登录时绑定的 IP 不同,按原规则每次都会被判为 + * 「ip地址发生变化」。DesktopController::session 出于同一原因已免登录。 + * 这里只在「RPA 终端令牌 + 请求来自 .env [WECOM_RPA] SERVER_IPS 配置的客服后台服务器 IP」 + * 时跳过 IP 绑定,网页端/APP 等其他终端令牌仍保留 IP 保护;未配置时与原逻辑完全一致。 + * 只跳过 IP 校验,后续菜单权限校验照常执行,客服后台拿到的仍只是该账号角色允许的数据。 + * + * @param mixed $configured 逗号/空白分隔的 IP 列表,逐个精确匹配(IPv4/IPv6 字面量) + */ + public static function isTrustedRpaServerRequest(array $adminInfo, string $ip, $configured): bool + { + if (!in_array((int) ($adminInfo['terminal'] ?? 0), AdminTerminalEnum::wecomRpaTerminals(), true)) { + return false; + } + if ($ip === '' || !is_string($configured)) { + return false; + } + $trustedIps = preg_split('/[\s,]+/', trim($configured), -1, PREG_SPLIT_NO_EMPTY) ?: []; + + return in_array($ip, $trustedIps, true); + } + /** * 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord, * 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。 diff --git a/server/tests/AdminWecomRpaServerIpTest.php b/server/tests/AdminWecomRpaServerIpTest.php new file mode 100644 index 000000000..a5b11084b --- /dev/null +++ b/server/tests/AdminWecomRpaServerIpTest.php @@ -0,0 +1,96 @@ + 9, 'terminal' => 7, 'root' => 0, 'login_ip' => '198.51.100.10']; + $invoke = function (array $identity, string $ip, string $controller = 'tcm.diagnosis', string $action = 'lists') use ($app) { + $request = (new think\Request())->withServer(['REMOTE_ADDR' => $ip]); + $request->setController($controller); + $request->setAction($action); + $request->adminInfo = $identity; + $request->controllerObject = new class { + public function isNotNeedLogin(): bool { return false; } + }; + $app->instance('request', $request); + $result = (new AuthMiddleware())->handle($request, static fn() => ['code' => 1, 'msg' => 'passed']); + return is_array($result) ? $result : $result->getData(); + }; + $ipChanged = static fn(array $r): bool => $r['code'] === -1 && str_contains($r['msg'], 'ip地址发生变化'); + $setTrusted = static fn(string $value) => $app->env->set('wecom_rpa.server_ips', $value); + + rpaIpCheck(env('wecom_rpa.server_ips') === null, 'test process must not inherit WECOM_RPA_SERVER_IPS'); + rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.20')), 'missing config keeps the login IP check'); + $setTrusted(''); + rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.20')), 'empty config keeps the login IP check'); + + // [WECOM_RPA] SERVER_IPS 在 .env 里是逗号/空白分隔的列表 + $setTrusted(' 192.0.2.1,203.0.113.20 2001:db8::20 '); + rpaIpCheck(env('wecom_rpa.server_ips') === ' 192.0.2.1,203.0.113.20 2001:db8::20 ', 'section key maps to env'); + rpaIpCheck($invoke($desktop, '203.0.113.20')['code'] === 1, 'desktop token from the kefu server passes'); + rpaIpCheck($invoke(array_replace($desktop, ['terminal' => 9]), '203.0.113.20')['code'] === 1, + 'mobile console token from the kefu server passes'); + rpaIpCheck($invoke(array_replace($desktop, ['terminal' => '7']), '2001:db8::20')['code'] === 1, + 'IPv6 server address and string terminal from cache pass'); + rpaIpCheck($invoke($desktop, '198.51.100.10')['code'] === 1, 'desktop token from the login IP still passes'); + + rpaIpCheck($ipChanged($invoke($desktop, '198.51.100.99')), 'desktop token from an untrusted IP is rejected'); + rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.2')), 'trusted IP must match exactly, not by prefix'); + rpaIpCheck($ipChanged($invoke($desktop, '2001:db8::2')), 'trusted IPv6 must match exactly'); + rpaIpCheck($ipChanged($invoke(array_replace($desktop, ['terminal' => 1]), '203.0.113.20')), + 'web token from the kefu server keeps IP protection'); + rpaIpCheck($ipChanged($invoke(array_replace($desktop, ['terminal' => 2]), '203.0.113.20')), + 'admin mobile web token keeps IP protection'); + rpaIpCheck($ipChanged($invoke(array_replace($desktop, ['terminal' => 8]), '203.0.113.20')), + 'AI browser console token keeps IP protection'); + rpaIpCheck($ipChanged($invoke(array_diff_key($desktop, ['terminal' => 0]), '203.0.113.20')), + 'token without terminal keeps IP protection'); + + // 跳过的只有 IP 校验:客服后台仍只能访问该账号角色允许的接口 + $denied = $invoke($desktop, '203.0.113.20', 'tcm.prescriptionOrder', 'lists'); + rpaIpCheck($denied['code'] === 0 && str_contains($denied['msg'], '权限不足'), 'menu permissions still apply'); + rpaIpCheck($invoke(array_replace($desktop, ['root' => 1]), '203.0.113.20', 'tcm.prescriptionOrder', 'lists')['code'] === 1, + 'root bypass is unchanged'); + + $setTrusted('true'); + rpaIpCheck(env('wecom_rpa.server_ips') === true, 'think Env converts true to bool'); + rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.20')), 'non-list config keeps the login IP check'); + + rpaIpCheck(!AuthMiddleware::isTrustedRpaServerRequest($desktop, '', ','), 'empty request IP never trusted'); + rpaIpCheck(!AuthMiddleware::isTrustedRpaServerRequest($desktop, '203.0.113.20', " ,\n\t"), 'separators only'); + rpaIpCheck(AuthMiddleware::isTrustedRpaServerRequest($desktop, '203.0.113.20', "192.0.2.1\n203.0.113.20"), + 'newline separated list'); + + // 示例配置能按 .env 规则解析且默认留空;生产 IP 只写在运维配置里,不硬编码进代码 + $exampleEnv = new think\Env(); + $exampleEnv->load(dirname(__DIR__) . '/.env.wecom-rpa.example'); + rpaIpCheck($exampleEnv->get('wecom_rpa.server_ips') === '', 'env example parses and ships an empty list'); + $middlewareSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php'); + rpaIpCheck(is_string($middlewareSource) && !str_contains($middlewareSource, '123.14.'), + 'kefu server IP is not hard-coded in the middleware'); + echo "AdminWecomRpaServerIpTest passed\n"; +}