Prechádzať zdrojové kódy

Finalize proxy connectivity and deployment compatibility

Support DeepSeek and QCC proxy transports, connection diagnostics, and DMS login routing. Isolate deployment connection settings and support process stopping when pidfd is unavailable.

Remove memory-based request admission while preserving shared concurrency slots and independent hard limits. Include regression tests and existing operational documentation updates.

Validation: offline initialization; scripts and step1/step3/step4/step5 synthetic test suites; shell syntax and Git whitespace checks. No live service or production deployment validation.
wangxi 2 týždňov pred
rodič
commit
01e4590a16

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 营商助手(Business Assistant)
 
-断网银河麒麟V10服务器使用项目资料包、ARM64 CPU环境包及`deploy-offline.sh`;目标机为aarch64/glibc 2.28,Python 3.14由部署同事安装,内核4.19的`pidfd`能力待复验。两个交付包已生成并校验,目标机安装尚未验证;现场操作以最新[部署操作指南](docs/deployment.md)为准。现有`deploy.sh`保留联网部署流程。
+断网银河麒麟V10服务器使用项目资料包、ARM64 CPU环境包及`deploy-offline.sh`;目标机为aarch64/glibc 2.28,Python 3.14由部署同事安装。F152已为缺少pidfd接口的环境增加核对进程身份后发送PID信号的重启兼容,现场替换后停启仍待验证。两个交付包已生成并校验,目标机安装尚未完整验证;现场操作以最新[部署操作指南](docs/deployment.md)为准。现有`deploy.sh`保留联网部署流程。
 
 ## 当前HTTP接口
 

+ 6 - 0
deploy-offline.sh

@@ -1,6 +1,10 @@
 #!/usr/bin/env sh
 # Linux offline deployment using the extracted single release archive.
 set -eu
+if [ "${1:-}" = '--env-file-only' ]; then
+    export DEPLOY_API_ENV_FILE_ONLY=1
+    shift
+fi
 CALLER_DIR=$(pwd)
 case "$0" in
   */*) SCRIPT_DIR=${0%/*} ;;
@@ -11,6 +15,8 @@ cd "$REPO_ROOT"
 ACTION=${1:-deploy}
 case "$ACTION" in
   help|-h|--help)
+    echo 'Optional first argument: --env-file-only (QCC/DeepSeek and DMS connection settings from .env; direct process restart inherits; systemd needs unit configuration)'
+    echo 'Example: sh deploy-offline.sh --env-file-only check-qcc "company full name"'
     echo 'Usage: sh deploy-offline.sh [deploy|import-knowledge|restart|start|stop|status|check-milvus|check-redis|check-dms|check-deepseek|check-qcc "company full name"|check "company full name"]'
     echo 'deploy: use the preinstalled .venv and packaged snapshot; never download'
     echo 'Usage: sh deploy-offline.sh import-knowledge [ZIP_PATH [DATABASE]]'

+ 5 - 0
deploy.sh

@@ -1,6 +1,10 @@
 #!/usr/bin/env sh
 # Linux deployment. No .env sourcing; all commands run from this checkout.
 set -eu
+if [ "${1:-}" = '--env-file-only' ]; then
+    export DEPLOY_API_ENV_FILE_ONLY=1
+    shift
+fi
 case "$0" in
   */*) SCRIPT_DIR=${0%/*} ;;
   *) SCRIPT_DIR=. ;;
@@ -10,6 +14,7 @@ cd "$REPO_ROOT"
 ACTION=${1:-deploy}
 case "$ACTION" in
   help|-h|--help)
+    echo 'Optional first argument: --env-file-only (QCC/DeepSeek and DMS connection settings from .env; direct process restart inherits; systemd needs unit configuration)'
     echo 'Usage: sh deploy.sh [deploy|update-knowledge|restart|start|stop|status|check "company full name"|check-milvus|check-redis|check-dms|check-deepseek|check-qcc "company full name"]'
     echo 'deploy: install uv/Python/dependencies/models; initialize missing knowledge; restart API'
     echo 'update-knowledge: prepare/verify with old API running; then stop, publish and start'

+ 127 - 2
docs/deployment.md

@@ -1,5 +1,130 @@
 # 部署操作指南
 
+## 重启缺少pidfd接口(F152,2026-10-15)
+
+148现场重启在环境检查后报`Deployment failed: AttributeError`,确认Python的`os.pidfd_open`及`signal.pidfd_send_signal`均不存在。旧脚本在停止旧进程时抛错,后续新进程不会启动;磁盘文件已替换不表示运行进程已更新。F152只需替换`scripts/deploy.py`,沿用F151最新版`src/step4_api/memory.py`,按用户要求不打包、不改.env。
+
+新脚本优先使用两项pidfd接口;缺少任一接口或内核返回ENOSYS/ENOTSUP时,使用传统PID信号。每次发送前再次核对工作目录、解释器、模块、PID启动时间及boot ID;身份已改变/进程已退出则不发送,权限或其他异常仍停止部署。先SIGTERM等待,未退出才复核后SIGKILL,旧服务确认退出后才启动新服务。兼容模式不能提供pidfd的原子身份固定保障,最终核对到信号发送之间仍有很小的PID复用窗口,不宣称完全消除该竞态。
+
+在项目目录执行原命令`sh deploy-offline.sh --env-file-only restart`。缺少接口时先显示`API stop mode: checked PID signals (pidfd unavailable).`,正常后台部署应随后出现`API stopped (only this checkout).`和`API ready. PID=...`;systemd托管显示对应的`Memory-isolated API ready (systemd).`。出现`Deployment failed`则整次重启未完成,不应继续假定新版本已生效。重启成功后运行`sh deploy-offline.sh status`,再复测原curl并检查最终SSE结果。
+
+依据:[Python 3.14 os.pidfd_open](https://docs.python.org/3.14/library/os.html#os.pidfd_open)、[pidfd_send_signal](https://docs.python.org/3.14/library/signal.html#signal.pidfd_send_signal)。本地为合成进程/信号验证,未在148实际停启服务。
+
+## DMS详细只读诊断
+
+2026-10-15更新:DMS_BASE_URL为主机(末尾斜杠可选),同时设置AUTH_PORT和DMS_PORT时,
+正式客户端自动使用认证端口的`/oauth/api/user/login`和数据端口的`/dms`。
+现有线上.env无需修改,check-dms、企业查询、会话处理及鉴权刷新共用该规则。
+显式/proxy_dms仍使用旧代理路径;其他配置规则见[DMS配置](dms-schema.md)。
+更新正式dms.py及deploy文件后运行`sh deploy-offline.sh --env-file-only check-dms`,
+通过后运行`sh deploy-offline.sh --env-file-only restart`和`sh deploy-offline.sh status`。
+栏目元数据检查不代表业务读写验收,还须从前端验证企业查询与会话保存。
+
+`scripts/test_dms_connection.py`须放在完整项目scripts目录,复用正式DMS配置/登录/鉴权刷新契约,
+读取服务器自己的.env与config/dms_schema.json,不覆盖这两个文件。
+默认输出通过/失败/未检查栏目数,仅展开失败栏目、异常字段及相关请求;加`--verbose`才打印下述全部过程与正常字段。
+运行`.venv/bin/python scripts/test_dms_connection.py --env-file-only --timeout 20`检查五栏目。
+`--column records|sessions|demands|basic|honor`可只检查一个栏目;严格范围不允许时在请求前拒绝。
+连接前打印实际API/登录地址、端口、超时和配置来源,逐次打印POST参数(id/modelId等)及响应状态;
+栏目结果列出名称/父级/模型,以及每个映射字段的期望类型、实际类型、missing/type_mismatch/matched。
+Token、账号、密码脱敏,不输出业务记录、登录返回正文或任意错误正文;只读元数据,不测试写入权限。
+默认urllib与正式客户端一致,保留环境HTTP代理。若怀疑环境代理,可再加`--no-env-proxy`对比,
+该选项仅改变诊断请求,不修改正式服务。网络错误区分DNS、连接拒绝、TLS、超时,HTTP状态单独显示。
+进度输出到stderr,最终报告输出到stdout;需完整保存时在命令后加`> dms-diagnostic.log 2>&1`。
+
+## DeepSeek与企查查代理更新(2026-10-15)
+
+正式QCC支持原HTTPS直连地址及带`servertype`、`proxyToken`的HTTP(S)代理地址。
+代理路径不自动追加,原query编码保留;业务参数只追加一次并URL编码,URL配置不能预含key/searchKey/keyword/pageIndex/pageSize。
+三接口代理均使用GET/http.client/Connection: keep-alive/Accept: */*,不走环境HTTP代理,不跟随重定向。
+每次请求实时生成MD5大写Token和Unix秒级Timespan;服务器须持续同步可靠时间源。
+公司身份核验、荣誉分页及完整性检查保留;荣誉为空可成功,分页达到上限仍标为不完整。
+`check-qcc`使用正式QccClient,要求搜索第一页中有唯一同名企业,再核对工商身份并查询荣誉分页。
+115现在以安全错误码`qcc_auth_failed`报告;检查时钟、Key/Secret配对与头透传,不能当作无结果。
+
+更新代码后在项目根目录依次执行(检查会实际调用外部接口):
+
+```sh
+sh deploy-offline.sh --env-file-only check-deepseek
+sh deploy-offline.sh --env-file-only check-qcc "用于联调的企业完整名称"
+sh deploy-offline.sh --env-file-only restart
+sh deploy-offline.sh status
+```
+
+`--env-file-only`必须放在动作之前,供deploy.sh和deploy-offline.sh共用:仅清除继承的QCC API配置
+(含超时/荣誉页数)、DEEPSEEK_API_KEY/BASE_URL/MODEL,以及DMS_BASE_URL/LOGIN_URL、AUTH_PORT、DMS_PORT、
+DMS_USERNAME/PASSWORD/TOKEN,使它们从根.env读取。DMS栏目ID、严格作用域等配置不受此选项影响。
+此选项对本次检查/启动生效;后续重启也应带上。自行使用systemd启动时,应同时清理unit中冲突的环境变量。
+离线服务器用deploy-offline.sh,不运行会同步依赖的在线deploy.sh restart;无需重装依赖或导入知识。
+DeepSeek普通/流式及部署检查使用共享传输,完整chat/completions地址不会重复拼接;实际配置不进入替换包。
+
+## 企查查代理独立诊断
+
+现场建议使用`.venv/bin/python scripts/test_qcc_proxy.py --env-file-only --mode proxy --api search --company '准确企业全称' --timeout 30`,
+显式只读取项目根.env,避免旧环境变量覆盖凭据或URL;默认仍兼容环境变量优先,并输出覆盖字段名,不输出值。
+每次请求实时生成Unix秒级Timespan和MD5大写Token,不复用固定签名。
+输出`request_time_utc`、`response_date_minus_request_seconds`(响应Date减签名时间)和`clock_skew_suspected`。
+Date缺失、重复或非法时不作偏差判断;偏差大于请求耗时加60秒仅提示核对时钟,60秒不是供应商签名有效期。
+Date可能由中间代理或缓存生成,不能据此自动校时或改签名。115时检查运行机器时钟、凭据配对与鉴权头透传。
+
+同一脚本可用`--mode direct --api search`测试本地`.env`里的无query直连地址;默认`--mode proxy`
+仍要求servertype/proxyToken。两种模式的GET业务参数、签名、http.client、keep-alive和Accept保持一致,
+仅代理模式保留配置中已有的路由参数。不要将本地真实凭据或公司正文复制到对比记录。
+
+在项目根目录运行`env -u QCC_SEARCH_URL -u QCC_BASIC_URL -u QCC_HONOR_URL .venv/bin/python scripts/test_qcc_proxy.py --company '准确企业全称'`。
+脚本只需标准库,读取根`.env`的三个URL及`QCC_KEY`/`QCC_SECRET_KEY`,其余环境变量仍优先。
+URL须单行,代理参数名精确为`servertype`和`proxyToken`;路由值按实际配置原样发送,不自动纠正大小写。
+使用GET、http.client、keep-alive及现有QCC Token/Timespan签名,原query后追加业务参数,不追加接口路径。
+默认三个接口各请求一次,搜索和荣誉只测第一页;可用`--api search|basic|honor`单测及`--timeout 30`。
+输出HTTP/业务状态及记录数、无query的endpoint、路由值和参数名称;HTTP200不等于业务成功。
+失败时增加`error_body_preview`(脱敏后最多2000字符)、`error_body_truncated`及白名单响应头
+(响应类型、Server、传输头、Date与请求追踪ID,重复头保留为列表)。支持HTML错误页及业务Message;
+已知密钥、代理Token、签名和查询公司名称脱敏,JSON Result/Data省略,成功响应不打印资料正文。
+搜索201单独判为无匹配,荣誉无记录可成功;不代表三接口企业身份关联或全量分页已验收。
+独立探针只检查单接口协议;正式适配与联合检查见上节,不以探针代替正式服务验证。
+
+## DeepSeek固定代理入口独立诊断
+
+使用固定聊天代理时,可在项目根目录执行:
+
+```sh
+.venv/bin/python scripts/test_deepseek_proxy.py
+```
+
+该脚本读取根目录`.env`中的完整`DEEPSEEK_BASE_URL`,系统同名变量优先。查询参数名为
+`servertype`和`proxyToken`,其中`servertype=deepseek`;URL须写在同一行,不添加转义反斜杠。
+脚本默认POST完整URL,不追加`/chat/completions`;已配置的`/chat/completions/`会去掉末尾斜杠。
+`servertype`和`proxyToken`保留在URL中,
+`model`和`messages`等聊天参数放在JSON请求体中。可加`--method GET`仅诊断入口连通性。
+Bearer密钥存在时发送,未配置则不伪造鉴权;响应中的凭据脱敏。无需额外安装python-dotenv。
+输出HTTP状态、响应摘要及错误分类,`http_ok`仅表示HTTP为2xx;只有完整且有效非空聊天响应
+才`ok=true`/退出0。GET拿到普通代理响应不能证明聊天生成可用,需代理提供聊天请求转发协议。
+`chat_payload_sent`指示本次是否发送聊天JSON,GET为false。
+响应提前结束时保留脱敏片段及`response_complete=false`、`received_bytes`、`content_length`、
+`transfer_encoding`;HTTP500仍显示`http_error`,不能把接收不完整的HTTP200报成功。
+此脚本绕过系统HTTP代理,不跟随重定向;真实代理是否可用须在服务器运行确认,
+独立测试不替代正式服务的端到端验证。
+独立脚本使用标准库`http.client`发送`Connection: keep-alive`,输出
+`transport=http.client`和`request_connection=keep-alive`供确认版本;单次请求结束后主动关闭连接。
+该脚本为普通JSON测试(`stream=false`)。独立流式诊断使用
+`env -u DEEPSEEK_BASE_URL .venv/bin/python scripts/test_deepseek_proxy_stream.py --timeout 60`,
+同样采用`http.client`和keep-alive,发送`stream=true`及`Accept: */*`以兼容代理内容协商。
+流式成功需`code=stream_success`、完整HTTP响应、非空正文、`finish_reason=stop`及`done_received=true`。
+输出首段/末段时间、内容段数及前100段到达时间,不输出推理字段;时间仅辅助判断缓冲,
+单次短回答不证明始终实时透传。
+
+正式摘要、问答、流式及`sh deploy-offline.sh check-deepseek`共用DeepSeek地址解析。
+`DEEPSEEK_BASE_URL`支持HTTP(S)基础地址或完整聊天地址:基础路径只追加一次
+`/chat/completions`,完整聊天路径去掉末尾斜杠且不重复追加,查询字符串原样保留。
+例如`http://gateway.example:10011/proxy/chat/completions?servertype=deepseek&proxyToken=<TOKEN>`。
+`model`、`messages`等使用POST JSON,API Key使用Authorization头;包含上述代理参数时
+普通与流式均使用`http.client`、`Connection: keep-alive`和`Accept: */*`,绕过系统HTTP代理。
+每次请求独立连接并在完成、失败或取消后关闭,不自动跟随重定向;流式逐段读取并检查HTTP及SSE结束。
+直连API配置仍沿用原urllib及系统代理行为。禁止URL内嵌用户名密码、片段和空白。
+`check-deepseek`复用正式普通传输,输出预算128与已验证探针对齐。
+部署检查不输出URL或凭据,响应传输不完整报告`response_incomplete`。
+替换正式代码后需重启服务;代理的SSE透传及实际线上问答仍须验证。
+
 ## 麒麟 V10 断网部署(ARM64 / CPU)
 
 交付`offline-release-kylin-20260929-r3.zip`(项目、`.env`、知识和模型,**仅限受控传输**)、`offline-env-arm64-cpu-20260929-r2.zip`(uv 和依赖)及对应的`offline-checksums-20260929-r3.txt`。不要混用旧项目包。目标机需先安装标准 CPython 3.14;目前尚未在目标机完成安装验证。
@@ -12,7 +137,7 @@ Python 安装后,将`scripts/collect-offline-env.sh`单独传至服务器并
 sh collect-offline-env.sh > offline-env.txt 2>&1
 ```
 
-把`offline-env.txt`发回。确认输出为`aarch64`、glibc不低于2.28、Python 3.14且`pidfd: available`;任一项不符,先暂停部署。若脚本退出1,仍请发回输出。当前只准备CPU依赖,不使用CUDA。
+把`offline-env.txt`发回。确认输出为`aarch64`、glibc不低于2.28、Python 3.14;任一项不符,先暂停部署。F152后pidfd可用时优先使用,缺失时采用上述核对身份的PID信号兼容路径;应先更新scripts/deploy.py。若脚本退出1,仍请发回输出。当前只准备CPU依赖,不使用CUDA。
 
 ### 2. 校验并解包
 
@@ -40,7 +165,7 @@ Python 不在`PATH`时,在命令前加`PYTHON_BIN=/绝对路径/python3.14`。
 
 ### 4. 政务网内检查DMS、恢复知识并启动
 
-确认`pidfd: available`、`.env`配置及 Milvus/Redis 等服务可达后,在可访问政务网的部署机先执行只读DMS检查,再恢复知识和启动:
+确认已更新F152重启兼容脚本、`.env`配置及 Milvus/Redis 等服务可达后,在可访问政务网的部署机先执行只读DMS检查,再恢复知识和启动:
 
 ```sh
 cd /srv/business-assistant

+ 7 - 1
docs/dms-schema.md

@@ -10,10 +10,16 @@
 
 本地和线上共用同一套客户端与`.env`配置项,不根据Git分支、主机名或字段前缀选择代码版本。`connection_urls`统一解析初次登录、业务请求和token刷新使用的地址;企业查询与会话分析共用这一入口。
 
+可选`DMS_LOGIN_URL`指定完整HTTP(S)登录地址(例如`http://auth.example:8888/oauth/api/user/login`),
+设置后优先于AUTH_PORT和默认`/proxy_oauth/user/login`拼接,初次登录及212/401后的刷新均使用此地址。
+禁止内嵌用户密码、query、fragment及空白;省略时按下表推导。`DMS_BASE_URL`可设置完整数据入口
+`http://data.example:10081/dms`;`DMS_PORT`若非空仍覆盖它的端口,建议删除旧端口项或留空以避免冲突。
+仅改变地址路由,表单字段与登录响应契约保持;线上服务协议仍须实际诊断验证。
+
 | 配置方式 | 登录请求 | 数据请求 |
 | --- | --- | --- |
 | `DMS_BASE_URL`为主机和可选端口,`AUTH_PORT`/`DMS_PORT`留空 | 原端口的`/proxy_oauth/user/login` | 原端口的`/proxy_dms` |
-| 设置`AUTH_PORT`与`DMS_PORT` | 认证端口的`/proxy_oauth/user/login` | 数据端口的`/proxy_dms` |
+| 主机URL不含路径且同时设置`AUTH_PORT`与`DMS_PORT` | 认证端口的`/oauth/api/user/login` | 数据端口的`/dms` |
 | 只设置其中一个端口 | 对应端口覆盖,另一项沿用URL端口 | 对应端口覆盖,另一项沿用URL端口 |
 | URL显式含`/proxy_dms` | 沿用同样的端口规则 | 保留`/proxy_dms`路径 |
 | URL显式含`/dms` | 有`AUTH_PORT`可自动登录;未设置时使用已有`DMS_TOKEN`,不猜认证地址 | 保留`/dms`路径,端口规则相同 |

+ 19 - 3
docs/memory-safety.md

@@ -1,10 +1,24 @@
 # 在线服务内存保护(F104)
 
-应用接纳保护默认启用,重启API后生效;Linux硬隔离必须在服务器安装并启用生成的systemd unit后才生效。本轮没有安装生产unit,没有进行服务器容量压测或真实OOM验证。
+**当前行为(F151,2026-10-15)**:用户反馈v1兼容替换重启后仍`memory_status_unavailable`,要求去除应用内存管理。`Admission.acquire()`现只检查共享任务名额,不读取RSS、MemAvailable或cgroup,也不按内存预算/任务预留拒绝;因此正常接纳不再产生`memory_status_unavailable`或`memory_busy`。问答、公司分类和后台扫描/分析均复用这一实现。共享名额`API_MEMORY_MAX_JOBS`、接口/模型/检索并发、同会话互斥及断线持有名额规则保持;满额仍返回429 `server_busy`。旧内存预算配置保留兼容解析与校验,但不参与运行时接纳,无需修改.env。
 
-## 应用接纳
+运行时只需替换`src/step4_api/memory.py`,按原部署入口重启后重试原curl;先前的独立`read_memory()`诊断仍可能报错,但已不参与请求接纳,不作为重启前提。Linux systemd硬隔离与附件解析子进程限额独立保留,不在本轮卸载;未安装unit时不会新增系统级限额。应用不再根据剩余内存预先拒绝工作,资源耗尽仍可能由系统终止进程。尚未执行148现场替换或真实问答验证。
 
-问答、公司分类、DMS后台扫描和后台分析共享一个Admission。锁内读取RSS及系统可用内存;Linux同时取可见cgroup v2各级剩余内存的最小值,Windows读取系统内存和进程工作集。读数失败保守拒绝。Windows没有本轮新增的服务级硬限制。
+以下应用采样和预算内容为F104/F150历史机制,已由F151取代;保留以便解释诊断函数和旧版本。Linux硬隔离必须在服务器安装并启用生成的systemd unit后才生效,仍按下方要求使用。
+
+## 历史应用内存接纳(F151已取消)
+
+问答、公司分类、DMS后台扫描和后台分析共享一个Admission。锁内读取RSS及系统可用内存;Linux同时取可见cgroup内存控制器各级剩余内存的最小值,Windows读取系统内存和进程工作集。读数失败保守拒绝。Windows没有本轮新增的服务级硬限制。
+
+F150(2026-10-15):应用内存采样兼容cgroup v1和v2。根据`/proc/self/cgroup`选择实际memory控制器,混合模式下memory归v1时优先读v1;v1按`/proc/self/mountinfo`定位挂载点及根,读取`memory.limit_in_bytes`和`memory.usage_in_bytes`,检查当前组和可见祖先,处理无限额、合并控制器、路径转义及命名空间。没有有限额时仍检查系统MemAvailable、进程RSS与任务预算;内存不足返回429,挂载/字段缺失、权限或解析失败仍返回503,不退化为忽略cgroup限制。此兼容只改变应用采样,下方生成的systemd硬隔离仍要求v2;v1不宣称支持该unit的硬隔离。
+
+148现场已在项目目录复现旧版`OSError: cgroup_v2_required`;当前终端的`N:memory:/路径`确认v1 memory控制器,不能由此假定独立容器或其他命名空间中的所有进程均相同。替换`src/step4_api/memory.py`后,在项目根目录用部署解释器只读验证:
+
+```sh
+"$(cat output/deploy/python-path)" -c 'from src.step4_api.memory import read_memory; print(read_memory())'
+```
+
+在F150版本中预期输出`Memory(rss=..., available=...)`而非traceback;诊断进程的RSS不是API进程RSS。用户反馈现场重启后仍503,第二次采样失败原因未确认。F151不再调用此诊断函数接纳工作,现场重启后应直接验证`POST /api/chat`的回答或error,不再要求先通过采样。
 
 | 配置 | 默认 | 说明 |
 | --- | --- | --- |
@@ -53,6 +67,8 @@ MemoryHigh=硬预算87.5%、MemoryMax=硬预算、MemorySwapMax=0,预算覆盖
 
 官方依据:[资源控制](https://github.com/systemd/systemd/blob/main/man/systemd.resource-control.xml)、[故障和重启](https://github.com/systemd/systemd/blob/main/man/systemd.service.xml)。
 
+采样字段及层级依据:[Linux cgroup v1内存控制器](https://docs.kernel.org/admin-guide/cgroup-v1/memory.html)、[Linux cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)。v1的usage为内核近似计数,应用预留不构成容量保证;只读取当前命名空间可见的祖先,未实测生产负载或隐藏祖先的余量。
+
 ## 验证与边界
 
 合成测试覆盖并发不超售、内存/可用量拒绝、采样失败、恢复、提交/工作失败释放、断线持有、前后台共享、延后不耗尝试次数;模拟cgroup祖先及命名空间、缺失硬限制拒绝启动、检查unit参数与部署无回退。未耗尽真实内存、未真实模型/DMS测试,未在Linux验证unit及解析器硬限制。

+ 5 - 5
docs/step4-api.md

@@ -6,9 +6,9 @@
 
 `/api/chat`仅接受JSON对象的`thread_id`、`question`及可选`pictures`、`files`,不接收额外字段。输入校验失败在流开始前返回JSON错误;接纳后为HTTP 200 SSE,事件统一包裹`thread_id/request_id/data`。当前正文可通过`answer_start`、`answer_delta`、`answer_end`、`answer_abort`增量到达;最终`answer_end`应按`operation=replace`覆盖草稿,`result`只作完整快照。政策卡片的`source/item`可先于讲解正文到达。附件处理另有`attachments`事件,处理失败直接以`error`结束。
 
-同一`thread_id`并行提交会返回409 `thread_busy`;工作池满或内存接纳不足可能返回429 `server_busy`/`memory_busy`,内存状态不可读取返回503 `memory_status_unavailable`。这些接纳错误均携带`Retry-After: 2`。成功接纳后的超时、处理失败及附件失败须监听SSE `error`,不能只看HTTP状态。运行时的并发和内存限额分别见[并发配置](concurrency.md)、[内存保护](memory-safety.md)。
+同一`thread_id`并行提交会返回409 `thread_busy`;工作池或共享任务名额满返回429 `server_busy`。F151取消应用内存采样与预算拦截,正常接纳不再产生`memory_busy`/`memory_status_unavailable`;提交任务异常仍可503 `service_unavailable`。这些接纳错误均携带`Retry-After: 2`。成功接纳后的超时、处理失败及附件失败须监听SSE `error`,不能只看HTTP状态。运行时的并发和独立硬限制见[并发配置](concurrency.md)、[内存说明](memory-safety.md)。
 
-F104:问答、分类与后台共享内存接纳,默认4个重任务名额;超内存预算返回429 memory_busy,采样不可用返回503 memory_status_unavailable,均带Retry-After。断线不提前归还在途任务名额。部署硬隔离需安装systemd unit,见[内存保护](memory-safety.md)。
+F104历史(内存采样拦截已由F151取消):问答、分类与后台共享接纳,默认4个重任务名额;当前只按名额接纳,保留断线不提前归还在途任务名额。部署硬隔离仍需安装systemd unit,见[内存说明](memory-safety.md)。
 
 F080(2026-09-21):区域前提与最终末尾补充按序进入同一正文流;正常纯追加路径end前补齐delta,真实校验/链接改写仍保留replace。GraphRuntime去除重复开头,独立answer提示放在summary结束后,避免插入未结束正文。卡片优先保持,需重启;前端按[流式协议](token-streaming.md)去重替换。
 
@@ -153,8 +153,8 @@ stream_closed.reason为terminal_event(已把done/error事件交给响应层)
 | API_HOST | 127.0.0.1 | 监听IPv4/IPv6;局域网访问可设为 `0.0.0.0` 或本机局域网IP |
 | API_PORT | 8000 | 端口,1–65535 |
 | API_MAX_CONCURRENT_REQUESTS | 16 | 同时执行的问答数,1–128;超限立即429 |
-| API_MEMORY_MAX_JOBS / API_MEMORY_BUDGET_MB | 4 / 4096 | 问答、分类及后台共享的接纳名额与RSS预算;具体配置和拒绝语义见[内存保护](memory-safety.md) |
-| API_MEMORY_JOB_RESERVE_MB / API_MEMORY_MIN_AVAILABLE_MB | 256 / 512 | 每任务增长预留与系统可用内存底线,单位MiB |
+| API_MEMORY_MAX_JOBS / API_MEMORY_BUDGET_MB | 4 / 4096 | 共享接纳名额仍生效;F151取消RSS预算拦截,旧预算仅兼容解析,见[内存说明](memory-safety.md) |
+| API_MEMORY_JOB_RESERVE_MB / API_MEMORY_MIN_AVAILABLE_MB | 256 / 512 | F151后仅兼容解析与校验,不参与请求接纳,无需修改原配置 |
 | API_ATTACHMENT_BASE_URL | 空 | 相对附件路径的下载地址前缀;空值只接受完整HTTP(S)链接 |
 | API_LLM_CONCURRENCY | 8 | 进程内模型调用上限,1–128,覆盖意图、规划、逐项分析与综合说明 |
 | API_RETRIEVAL_CONCURRENCY | 4 | 进程内同时检索的请求上限,1–16 |
@@ -257,7 +257,7 @@ data: {"thread_id":"window-1","request_id":"...","data":{"status":"processing","
 
 前端可在状态栏显示最近 `progress.message`,用 `heartbeat.elapsed_seconds` 更新等待时长。`accepted` 也增加 `data.message` 作为立即反馈。到达 `interrupt` 提示用户输入,到达 `done`/`error` 停止等待动画;不要把进度提示拼进最终回答正文。`recommend` 包括原文分析、条件比较和综合说明,当前未拆成逐篇政策的内部实时进度。
 
-流开始前的错误为普通JSON `{"error":"固定错误码"}`:415类型错误、422参数非法、413过大、408请求体超时、409 `thread_busy`、429 `server_busy`或`memory_busy`、503 `memory_status_unavailable`或`service_unavailable`。接纳拒绝的409/429/503附`Retry-After: 2`;422附件链接错误还可能带`message`和`attachment`。不要自动重发已执行中的业务请求。流开始后无法更改HTTP状态,必须处理`error`事件和没有`done`的异常断流。
+流开始前的错误为普通JSON `{"error":"固定错误码"}`:415类型错误、422参数非法、413过大、408请求体超时、409 `thread_busy`、429 `server_busy`、503 `service_unavailable`。F151取消内存接纳错误`memory_busy`/`memory_status_unavailable`,旧版本可能仍返回。接纳拒绝的409/429/503附`Retry-After: 2`;422附件链接错误还可能带`message`和`attachment`。不要自动重发已执行中的业务请求。流开始后无法更改HTTP状态,必须处理`error`事件和没有`done`的异常断流。
 
 ## 前端读取
 

+ 8 - 0
knowledge/README.md

@@ -1,5 +1,13 @@
 # 第一阶段知识整理
 
+## 2026-10-09 16:48 — F130 青浦介绍与新闻源更新入库
+
+最新青浦介绍与新闻源从998条调整为501条,保留内容未变、移除497条;其他来源3268条保持,现六库3769条、article824条。完整规整、摘要复用、实际入库与独立回读通过;原目录和集合保留,在线进程需重启加载新清单。
+
+## 2026-10-09 15:47 — F126 规范性文件增量加入文章库
+
+新增`reference/区政府规范性文件和部门街镇规范性文件.json`的35条规范性文件,按general/article规整,标题、完整原文、来源链接、更新时间及原始字段保留。旧4231条知识与摘要逐字段保持;现共4266条,article1321条。新增摘要和真实入库及独立复验完成;文件现行有效性与真实问答未验收,服务需重启加载新manifest。
+
 ## 2026-09-24 青浦区介绍与新闻(F117)
 
 `reference/青浦区介绍和新闻.json`新增998条,统一按general/article规整;含1条百科介绍和997条青浦区政府网站新闻。保留各条标题、全文、原链接与源文件更新时间;新闻中的历史措施只作为当时报道,不自动认定现行政策或可申报权益。当前规整总计4231条(general2948、policy170、finance185、qa928),新增记录均使用LLM生成检索摘要,旧3233条增强知识保持。摘要与入库结果见[摘要生成](summary-generation.md)和[知识入库](ingestion.md)。

+ 4 - 0
knowledge/ingestion.md

@@ -1,5 +1,9 @@
 # 第二步:知识入库
 
+## 2026-10-09 16:48 — F130 青浦介绍与新闻源更新入库
+
+新闻源更新后六库3769条:policy170、finance185、qa928、article824、service_guide1604、service_catalog58。新快照`7ad510acc1e0a8fd5259ea12f45af77518e74c30143e97019e8e2d2d203cacf7`,生成0个向量、复用3769个,完整写入回读及独立verify-only通过;旧目录output/f130-backup及旧集合保留,服务需重启加载新manifest。
+
 ## 2026-10-09 15:47 — F126 规范性文件增量加入文章库
 
 新增35条规范性文件进入article库,当前六库共4266条:policy170、finance185、qa928、article1321、service_guide1604、service_catalog58。快照`55cf59460978742e3d0e5ddc933b486a15ac4a5c9757d4509aaf324b75d6cd73`;生成35个向量、复用4231个,实际写入全量回读及独立`--verify-only`均通过。旧集合及`output/f126-backup`保留,服务需重启加载新manifest。全量知识包为`output/knowledge-snapshot-55cf5946.zip`,已独立验证并附SHA256;此更新未改造离线导入命令的不同快照冲突保护。

+ 8 - 0
knowledge/summary-generation.md

@@ -1,5 +1,13 @@
 # 五类场景摘要生成
 
+## 2026-10-09 16:48 — F130 青浦介绍与新闻源更新入库
+
+本次新闻源仅移除497条,保留501条按来源链接/完整原始字段/摘要事实指纹核对一致,完整复用已有摘要、索引文本和元数据,没有模型请求;其余3268条增强记录保持。报告prepared/f130_summary_report.json;摘要未重新业务评审。
+
+## 2026-10-09 15:47 — F126 规范性文件增量加入文章库
+
+仅对新增35条规范性文件调用已配置的DeepSeek官方接口生成article摘要,用户明确确认后执行;全部generated/stop,独立复验通过,旧4231条增强记录保持。操作脚本为`output/checks/f126-article-summary.py`,报告为`prepared/f126_summary_report.json`;发布后复验需传`--prepared knowledge/prepared --baseline output/f126-backup --verify`。摘要未逐条业务审核,不代表文件现行有效性。
+
 ## 2026-09-24 青浦区介绍与新闻(F117)
 
 用户授权后,仅对新增998条通用知识按article场景调用现有DeepSeek配置生成摘要。初轮8条返回格式不符合单字段JSON约束,定向重试后5条成功,余3条采用更明确的单字段要求成功;最终998条均为generated/stop,`output/checks/f117-news-summary.py --verify`独立复验通过。原有3233条增强记录保持一致;完整原文及链接仍单独保存,摘要不代表新闻所述政策当前有效。增量报告为`prepared/f117_summary_report.json`。

+ 53 - 9
scripts/deploy.py

@@ -1,6 +1,7 @@
 """Linux deployment controller. Only manages the API belonging to this checkout."""
 import argparse
 from contextlib import closing, contextmanager
+import errno
 import ipaddress
 import json
 import os
@@ -97,18 +98,49 @@ class Deployment:
         return found
 
     def send(self, identity, sig):
-        if self.identity(identity['pid']) != identity:
+        pid = identity['pid']
+        if type(pid) is not int or pid <= 1:
+            raise RuntimeError('invalid_api_process_identity')
+        if self.identity(pid) != identity:
+            return
+        opened = getattr(os, 'pidfd_open', None)
+        send_fd = getattr(signal, 'pidfd_send_signal', None)
+        # Prefer a kernel-pinned identity. Some older Python/Linux builds expose
+        # neither function, and some expose them on a kernel without support.
+        if callable(opened) and callable(send_fd):
+            try:
+                fd = opened(pid)
+            except ProcessLookupError:
+                return
+            except OSError as exc:
+                if exc.errno not in (errno.ENOSYS, errno.ENOTSUP):
+                    raise
+            else:
+                try:
+                    if self.identity(pid) != identity:
+                        return
+                    try:
+                        send_fd(fd, sig)
+                        return
+                    except ProcessLookupError:
+                        return
+                    except OSError as exc:
+                        if exc.errno not in (errno.ENOSYS, errno.ENOTSUP):
+                            raise
+                finally:
+                    os.close(fd)
+        if not getattr(self, '_pid_signal_notice', False):
+            print('API stop mode: checked PID signals (pidfd unavailable).', flush=True)
+            self._pid_signal_notice = True
+        # Recheck cwd, command, owned interpreter, start ticks and boot ID as
+        # close to kill() as possible. Unlike pidfd this cannot atomically pin
+        # the process; do not claim to eliminate the final PID-reuse race.
+        if self.identity(pid) != identity:
             return
-        # pidfd pins the kernel process; never signal a newly reused numeric PID.
         try:
-            fd = os.pidfd_open(identity['pid'])
+            os.kill(pid, sig)
         except ProcessLookupError:
             return
-        try:
-            if self.identity(identity['pid']) == identity:
-                signal.pidfd_send_signal(fd, sig)
-        finally:
-            os.close(fd)
 
     def stop(self):
         if self.systemd_managed():
@@ -369,7 +401,19 @@ class Deployment:
             return 0
 
 
+def apply_api_env_policy():
+    """Load external API and DMS connection settings only from .env when requested."""
+    if os.environ.get('DEPLOY_API_ENV_FILE_ONLY') == '1':
+        for name in ('QCC_KEY', 'QCC_SECRET_KEY', 'QCC_TIMEOUT_SECONDS', 'QCC_HONOR_MAX_PAGES',
+                     'QCC_SEARCH_URL', 'QCC_BASIC_URL', 'QCC_HONOR_URL',
+                     'DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_MODEL',
+                     'DMS_BASE_URL', 'DMS_LOGIN_URL', 'AUTH_PORT', 'DMS_PORT',
+                     'DMS_USERNAME', 'DMS_PASSWORD', 'DMS_TOKEN'):
+            os.environ.pop(name, None)
+
+
 def main():
+    apply_api_env_policy()
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument('action', choices=('deploy', 'import-knowledge', 'update-knowledge', 'restart', 'start', 'stop',
                                            'status', 'serve', 'check', 'check-milvus', 'check-redis', 'check-dms',
@@ -383,7 +427,7 @@ def main():
     elif args.action != 'import-knowledge' and (args.argument is not None or args.database is not None):
         parser.error('only import-knowledge accepts a ZIP path and optional library name')
     if not sys.platform.startswith('linux'):
-        parser.error('Linux with /proc and pidfd (kernel >= 5.3) is required')
+        parser.error('Linux with /proc is required')
     try:
         if args.action == 'import-knowledge':
             return Deployment().execute(args.action, archive=args.argument, database=args.database)

+ 5 - 4
scripts/deploy_check.py

@@ -114,7 +114,7 @@ def deepseek(root, send=None):
 
     config = load_config(root / '.env')
     payload = {'model': config.model, 'messages': [{'role': 'user', 'content': 'Reply with OK.'}],
-               'max_tokens': 16, 'stream': False}
+               'max_tokens': 128, 'stream': False}
     response = (send or transport)(payload, config)
     choices = response.get('choices') if isinstance(response, dict) else None
     content = choices[0].get('message', {}).get('content') if isinstance(choices, list) and choices and isinstance(choices[0], dict) else None
@@ -157,16 +157,17 @@ def qcc(root, company_name, client=None):
 
 def safe_code(exc):
     code = str(exc)
-    allowed = {'provider_failure', 'invalid_result', 'invalid_search_result', 'invalid_candidate',
+    allowed = {'provider_failure', 'qcc_auth_failed', 'invalid_qcc_configuration', 'invalid_result', 'invalid_search_result', 'invalid_candidate',
                'duplicate_candidate', 'company_identity_mismatch', 'invalid_honor_result',
                'invalid_honor_data', 'inconsistent_honor_result', 'inconsistent_honor_paging',
                'page_limit', 'network_failure', 'invalid_json', 'response_too_large',
-               'invalid_dms_configuration', 'dms_scope_denied', 'dms_redirect_refused',
+               'invalid_dms_configuration', 'invalid_dms_login_url', 'dms_scope_denied', 'dms_redirect_refused',
                'dms_transport_failed', 'dms_invalid_envelope', 'dms_permission_denied',
                'dms_token_invalid', 'dms_service_failed', 'dms_auth_retry_later',
                'dms_login_failed', 'dms_direct_api_requires_token', 'dms_column_mismatch',
                'dms_parent_mismatch', 'dms_schema_mismatch', 'dms_write_scope_denied',
-               'dms_operation_denied'}
+               'dms_operation_denied', 'invalid_deepseek_base_url', 'missing_deepseek_configuration',
+               'request_timeout', 'response_incomplete', 'invalid_response_json'}
     return code if code in allowed or (code.startswith('http_') and code[5:].isdigit()) else 'request_failed'
 
 

+ 203 - 0
scripts/test_deepseek_proxy.py

@@ -0,0 +1,203 @@
+"""Read .env and diagnose the fixed DeepSeek gateway (POST by default).
+
+Run on the deployment server: uv run --frozen python scripts/test_deepseek_proxy.py
+No OpenAI SDK path joining, retries, or response cache is used.
+"""
+
+from __future__ import annotations
+
+import argparse
+from http.client import HTTPConnection, HTTPSConnection, IncompleteRead
+import json
+import os
+import re
+import socket
+import sys
+import time
+from pathlib import Path
+from urllib.parse import parse_qsl, quote, quote_plus, urlsplit, urlunsplit
+
+def load_env_values(path: Path) -> dict[str, str]:
+    """Read just the three diagnostic settings, with no third-party dependency."""
+    values = {}
+    if not path.is_file():
+        return values
+    for line in path.read_text(encoding="utf-8-sig").splitlines():
+        line = line.strip()
+        if line.startswith("export "):
+            line = line[7:]
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        key, value = key.strip(), value.strip()
+        if key not in {"DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"}:
+            continue
+        if value.startswith(("'", '"')):
+            quote_char = value[0]
+            end = value.find(quote_char, 1)
+            if end < 0 or (value[end + 1:].strip() and not value[end + 1:].strip().startswith("#")):
+                raise ValueError("invalid_env_quoting")
+            value = value[1:end]
+        else:
+            value = value.split(" #", 1)[0].strip()
+        values[key] = value
+    return values
+
+
+def _redact(value: str, secrets: list[str]) -> str:
+    for secret in sorted(set(secrets), key=len, reverse=True):
+        if secret:
+            for variant in (secret, quote(secret, safe=""), quote_plus(secret)):
+                value = value.replace(variant, "[REDACTED]")
+    value = re.sub(r"(?i)(proxyToken[\"'\s]*[:=][\"'\s]*)[^&\s\"'<>]+", r"\1[REDACTED]", value)
+    return re.sub(r"(?i)(Bearer\s+)\S+", r"\1[REDACTED]", value)
+
+
+def probe(*, timeout: float = 30, method: str = "POST") -> dict:
+    """Return safe diagnostics; environment variables override the root .env."""
+    if method not in {"GET", "POST"}:
+        return {"ok": False, "code": "invalid_method"}
+    env_file = Path(__file__).resolve().parents[1] / ".env"
+    try:
+        values = load_env_values(env_file)
+    except (OSError, ValueError):
+        return {"ok": False, "code": "invalid_env_file", "hint": ".env无法读取或引号格式无效"}
+    values.update({key: os.environ[key] for key in (
+        "DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"
+    ) if key in os.environ})
+    url = values.get("DEEPSEEK_BASE_URL", "").strip()
+    api_key = values.get("DEEPSEEK_API_KEY", "").strip()
+    model = values.get("DEEPSEEK_MODEL", "deepseek-flash").strip()
+    result = {
+        "ok": False,
+        "env_file_exists": env_file.is_file(),
+        "api_key_configured": bool(api_key),
+        "environment_proxy_configured": any(os.environ.get(k) for k in (
+            "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"
+        )),
+        "use_environment_proxy": False,
+        "transport": "http.client",
+        "request_connection": "keep-alive",
+    }
+    secrets = [api_key]
+    try:
+        parts = urlsplit(url)
+        params = parse_qsl(parts.query, keep_blank_values=True)
+        secrets.extend(value for key, value in params if key.lower() == "proxytoken")
+        if (parts.scheme not in ("http", "https") or not parts.hostname
+                or parts.username or parts.password or parts.fragment
+                or any(char.isspace() for char in url)):
+            raise ValueError
+        _ = parts.port
+        if parts.path.rstrip("/").endswith("/chat/completions"):
+            parts = parts._replace(path=parts.path.rstrip("/"))
+            url = urlunsplit(parts)
+    except ValueError:
+        return {**result, "code": "invalid_base_url", "hint": "DEEPSEEK_BASE_URL须为单行完整HTTP(S)代理URL"}
+    result.update(endpoint=f"{parts.scheme}://{parts.netloc}{parts.path}",
+                  query_keys=sorted({key for key, _ in params}),
+                  model=_redact(model, secrets), method=method,
+                  chat_payload_sent=method == "POST")
+    query = dict(params)
+    if query.get("servertype") != "deepseek" or not query.get("proxyToken"):
+        return {**result, "code": "missing_proxy_params", "hint": "URL须包含servertype=deepseek及非空proxyToken,注意参数大小写"}
+
+    payload = {
+        "model": model,
+        "messages": [{"role": "user", "content": "1+1等于多少"}],
+        "max_tokens": 128,
+        "stream": False,
+    }
+    headers = {"Accept": "application/json", "Connection": "keep-alive"}
+    if method == "POST":
+        headers["Content-Type"] = "application/json"
+    if api_key:
+        headers["Authorization"] = f"Bearer {api_key}"
+    # GET probes the documented gateway URL only. Do not guess how a custom
+    # gateway encodes upstream chat requests in its query parameters.
+    request_body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if method == "POST" else None
+    started = time.perf_counter()
+    connection = None
+    try:
+        connection_type = HTTPSConnection if parts.scheme == "https" else HTTPConnection
+        connection = connection_type(parts.hostname, parts.port, timeout=timeout)
+        target = (parts.path or "/") + ("?" + parts.query if parts.query else "")
+        # Direct connection: no environment proxy, redirects, or automatic retry.
+        connection.request(method, target, body=request_body, headers=headers)
+        response = connection.getresponse()
+        with response:
+            result["http_status"] = response.status
+            result["http_ok"] = 200 <= response.status < 300
+            result["content_type"] = _redact(response.headers.get("Content-Type", ""), secrets)
+            result["transfer_encoding"] = _redact(response.headers.get("Transfer-Encoding", ""), secrets)[:120]
+            length = response.headers.get("Content-Length", "")
+            expected_length = int(length) if length.isascii() and length.isdecimal() else None
+            result["content_length"] = expected_length
+            result["response_complete"] = True
+            try:
+                raw = response.read(1024 * 1024 + 1)
+            except IncompleteRead as exc:
+                # Malformed/unfinished chunked responses can contain the useful
+                # gateway error text in partial; do not discard that evidence.
+                raw = exc.partial
+                result.update(response_complete=False, error_type="IncompleteRead")
+            if (expected_length is not None and not result["transfer_encoding"]
+                    and len(raw) < expected_length):
+                result.update(response_complete=False, error_type="ContentLengthMismatch")
+            result["received_bytes"] = len(raw)
+        if len(raw) > 1024 * 1024:
+            result["code"] = "response_too_large"
+            return result
+        body = raw.decode("utf-8", errors="replace")
+        result["response_preview"] = _redact(body, secrets)[:1200]
+        if "authentication fails" in body.lower() and "governor" in body.lower():
+            result.update(code="proxy_authentication_failed", hint="代理已响应但鉴权失败,请核对参数大小写、token及代理权限")
+        elif not 200 <= result["http_status"] < 300:
+            result["code"] = "http_error"
+        elif not result["response_complete"]:
+            result["code"] = "response_incomplete"
+        else:
+            try:
+                data = json.loads(body)
+                content = data["choices"][0]["message"]["content"]
+                if not isinstance(content, str) or not content.strip():
+                    raise ValueError
+                result.update(ok=True, code="chat_success", assistant_text=_redact(content, secrets)[:1200])
+            except (ValueError, KeyError, TypeError, IndexError):
+                result.update(code="unexpected_response", hint="HTTP成功但不是含非空choices[0].message.content的聊天响应;需核对代理返回协议")
+        if not result["response_complete"]:
+            result["hint"] = (
+                f"已收到HTTP {result['http_status']}响应头,但响应体传输不完整;"
+                + ("未取得可解析的正文。" if not raw else "response_preview为已收到的脱敏片段。")
+                + "HTTP 200不代表聊天成功,请代理同事检查重复Transfer-Encoding、分块编码及结束标记"
+            )
+    except (TimeoutError, socket.timeout):
+        result["code"] = "timeout"
+    except OSError as exc:
+        result["code"] = "connection_failed"
+        result["error_type"] = type(exc).__name__
+    except Exception as exc:
+        # Do not expose exceptions containing the complete URL or credentials.
+        result.update(code="request_failed", error_type=type(exc).__name__)
+    finally:
+        if connection is not None:
+            connection.close()
+        result["elapsed_seconds"] = round(time.perf_counter() - started, 3)
+    return result
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="读取.env,测试固定DeepSeek代理入口(默认POST,一次请求)")
+    parser.add_argument("--timeout", type=float, default=30, help="请求超时秒数,默认30")
+    parser.add_argument("--method", choices=("GET", "POST"), default="POST",
+                        help="默认POST发送最小聊天JSON;GET仅请求代理入口")
+    args = parser.parse_args()
+    if not 0 < args.timeout <= 120:
+        parser.error("--timeout须大于0且不超过120秒")
+    result = probe(timeout=args.timeout, method=args.method)
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0 if result["ok"] else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 314 - 0
scripts/test_deepseek_proxy_stream.py

@@ -0,0 +1,314 @@
+"""Standalone SSE diagnostic using http.client and keep-alive.
+
+Run: .venv/bin/python scripts/test_deepseek_proxy_stream.py
+No OpenAI SDK path joining, retries, or response cache is used.
+"""
+
+from __future__ import annotations
+
+import argparse
+from http.client import HTTPConnection, HTTPSConnection, IncompleteRead
+import json
+import os
+import re
+import socket
+import sys
+import time
+from pathlib import Path
+from urllib.parse import parse_qsl, quote, quote_plus, urlsplit, urlunsplit
+
+def load_env_values(path: Path) -> dict[str, str]:
+    """Read just the three diagnostic settings, with no third-party dependency."""
+    values = {}
+    if not path.is_file():
+        return values
+    for line in path.read_text(encoding="utf-8-sig").splitlines():
+        line = line.strip()
+        if line.startswith("export "):
+            line = line[7:]
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        key, value = key.strip(), value.strip()
+        if key not in {"DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"}:
+            continue
+        if value.startswith(("'", '"')):
+            quote_char = value[0]
+            end = value.find(quote_char, 1)
+            if end < 0 or (value[end + 1:].strip() and not value[end + 1:].strip().startswith("#")):
+                raise ValueError("invalid_env_quoting")
+            value = value[1:end]
+        else:
+            value = value.split(" #", 1)[0].strip()
+        values[key] = value
+    return values
+
+
+def _redact(value: str, secrets: list[str]) -> str:
+    for secret in sorted(set(secrets), key=len, reverse=True):
+        if secret:
+            for variant in (secret, quote(secret, safe=""), quote_plus(secret)):
+                value = value.replace(variant, "[REDACTED]")
+    value = re.sub(r"(?i)(proxyToken[\"'\s]*[:=][\"'\s]*)[^&\s\"'<>]+", r"\1[REDACTED]", value)
+    return re.sub(r"(?i)(Bearer\s+)\S+", r"\1[REDACTED]", value)
+
+
+def probe(*, timeout: float = 30, method: str = "POST") -> dict:
+    """Return safe diagnostics; environment variables override the root .env."""
+    if method != "POST":
+        return {"ok": False, "code": "invalid_method"}
+    env_file = Path(__file__).resolve().parents[1] / ".env"
+    try:
+        values = load_env_values(env_file)
+    except (OSError, ValueError):
+        return {"ok": False, "code": "invalid_env_file", "hint": ".env无法读取或引号格式无效"}
+    values.update({key: os.environ[key] for key in (
+        "DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"
+    ) if key in os.environ})
+    url = values.get("DEEPSEEK_BASE_URL", "").strip()
+    api_key = values.get("DEEPSEEK_API_KEY", "").strip()
+    model = values.get("DEEPSEEK_MODEL", "deepseek-flash").strip()
+    result = {
+        "ok": False,
+        "env_file_exists": env_file.is_file(),
+        "api_key_configured": bool(api_key),
+        "environment_proxy_configured": any(os.environ.get(k) for k in (
+            "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"
+        )),
+        "use_environment_proxy": False,
+        "transport": "http.client",
+        "request_connection": "keep-alive",
+        "stream": True,
+    }
+    secrets = [api_key]
+    try:
+        parts = urlsplit(url)
+        params = parse_qsl(parts.query, keep_blank_values=True)
+        secrets.extend(value for key, value in params if key.lower() == "proxytoken")
+        if (parts.scheme not in ("http", "https") or not parts.hostname
+                or parts.username or parts.password or parts.fragment
+                or any(char.isspace() for char in url)):
+            raise ValueError
+        _ = parts.port
+        if parts.path.rstrip("/").endswith("/chat/completions"):
+            parts = parts._replace(path=parts.path.rstrip("/"))
+            url = urlunsplit(parts)
+    except ValueError:
+        return {**result, "code": "invalid_base_url", "hint": "DEEPSEEK_BASE_URL须为单行完整HTTP(S)代理URL"}
+    result.update(endpoint=f"{parts.scheme}://{parts.netloc}{parts.path}",
+                  query_keys=sorted({key for key, _ in params}),
+                  model=_redact(model, secrets), method=method,
+                  chat_payload_sent=method == "POST")
+    query = dict(params)
+    if query.get("servertype") != "deepseek" or not query.get("proxyToken"):
+        return {**result, "code": "missing_proxy_params", "hint": "URL须包含servertype=deepseek及非空proxyToken,注意参数大小写"}
+
+    payload = {
+        "model": model,
+        "messages": [{"role": "user", "content": "1+1等于多少"}],
+        "max_tokens": 128,
+        "stream": True,
+    }
+    headers = {"Accept": "*/*", "Connection": "keep-alive"}
+    if method == "POST":
+        headers["Content-Type"] = "application/json"
+    if api_key:
+        headers["Authorization"] = f"Bearer {api_key}"
+    # GET probes the documented gateway URL only. Do not guess how a custom
+    # gateway encodes upstream chat requests in its query parameters.
+    request_body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if method == "POST" else None
+    started = time.perf_counter()
+    connection = None
+    try:
+        connection_type = HTTPSConnection if parts.scheme == "https" else HTTPConnection
+        connection = connection_type(parts.hostname, parts.port, timeout=timeout)
+        target = (parts.path or "/") + ("?" + parts.query if parts.query else "")
+        # Direct connection: no environment proxy, redirects, or automatic retry.
+        connection.request(method, target, body=request_body, headers=headers)
+        response = connection.getresponse()
+        with response:
+            result["http_status"] = response.status
+            result["http_ok"] = 200 <= response.status < 300
+            result["content_type"] = _redact(response.headers.get("Content-Type", ""), secrets)
+            result["transfer_encoding"] = _redact(response.headers.get("Transfer-Encoding", ""), secrets)[:120]
+            length = response.headers.get("Content-Length", "")
+            expected_length = int(length) if length.isascii() and length.isdecimal() else None
+            result["content_length"] = expected_length
+            if result['http_ok'] and result['content_type'].split(';')[0].strip().lower() == 'text/event-stream':
+                read_sse(response, result, secrets, started, timeout)
+                return result
+            result["response_complete"] = True
+            try:
+                raw = response.read(1024 * 1024 + 1)
+            except IncompleteRead as exc:
+                # Malformed/unfinished chunked responses can contain the useful
+                # gateway error text in partial; do not discard that evidence.
+                raw = exc.partial
+                result.update(response_complete=False, error_type="IncompleteRead")
+            if (expected_length is not None and not result["transfer_encoding"]
+                    and len(raw) < expected_length):
+                result.update(response_complete=False, error_type="ContentLengthMismatch")
+            result["received_bytes"] = len(raw)
+        if len(raw) > 1024 * 1024:
+            result["code"] = "response_too_large"
+            return result
+        body = raw.decode("utf-8", errors="replace")
+        result["response_preview"] = _redact(body, secrets)[:1200]
+        if "authentication fails" in body.lower() and "governor" in body.lower():
+            result.update(code="proxy_authentication_failed", hint="代理已响应但鉴权失败,请核对参数大小写、token及代理权限")
+        elif not 200 <= result["http_status"] < 300:
+            result["code"] = "http_error"
+        elif not result["response_complete"]:
+            result["code"] = "response_incomplete"
+        else:
+            try:
+                data = json.loads(body)
+                content = data["choices"][0]["message"]["content"]
+                if not isinstance(content, str) or not content.strip():
+                    raise ValueError
+                result.update(code="unexpected_response", hint="请求stream=true,但返回普通JSON;代理未返回SSE流")
+            except (ValueError, KeyError, TypeError, IndexError):
+                result.update(code="unexpected_response", hint="HTTP成功但不是含非空choices[0].message.content的聊天响应;需核对代理返回协议")
+        if not result["response_complete"]:
+            result["hint"] = (
+                f"已收到HTTP {result['http_status']}响应头,但响应体传输不完整;"
+                + ("未取得可解析的正文。" if not raw else "response_preview为已收到的脱敏片段。")
+                + "HTTP 200不代表聊天成功,请代理同事检查重复Transfer-Encoding、分块编码及结束标记"
+            )
+    except (TimeoutError, socket.timeout):
+        result["code"] = "timeout"
+    except OSError as exc:
+        result["code"] = "connection_failed"
+        result["error_type"] = type(exc).__name__
+    except Exception as exc:
+        # Do not expose exceptions containing the complete URL or credentials.
+        result.update(code="request_failed", error_type=type(exc).__name__)
+    finally:
+        if connection is not None:
+            connection.close()
+        result["elapsed_seconds"] = round(time.perf_counter() - started, 3)
+    return result
+
+
+def read_sse(response, result, secrets, started, timeout):
+    """Parse bounded SSE events incrementally, then verify HTTP framing to EOF."""
+    result.update(response_complete=False, done_received=False, finish_reason=None,
+                  content_chunks=0, received_bytes=0, first_content_seconds=None,
+                  last_content_seconds=None, content_arrival_seconds=[])
+    pending = b''
+    event = []
+    content = []
+    invalid = False
+    provider_error = False
+
+    def dispatch():
+        nonlocal invalid, provider_error
+        if not event:
+            return
+        value = b'\n'.join(event).decode('utf-8')
+        event.clear()
+        if result['done_received']:
+            invalid = True
+            return
+        if value == '[DONE]':
+            result['done_received'] = True
+            return
+        data = json.loads(value)
+        if not isinstance(data, dict):
+            invalid = True
+            return
+        if 'error' in data:
+            provider_error = True
+            return
+        choices = data.get('choices')
+        if choices == []:  # Optional usage frame.
+            return
+        if not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict):
+            invalid = True
+            return
+        choice = choices[0]
+        delta = choice.get('delta', {})
+        if choice.get('index', 0) != 0 or not isinstance(delta, dict):
+            invalid = True
+            return
+        text = delta.get('content')
+        if text is not None and not isinstance(text, str):
+            invalid = True
+            return
+        if text:
+            if result['finish_reason'] is not None:
+                invalid = True
+            content.append(text)
+            elapsed = round(time.perf_counter() - started, 3)
+            result['content_chunks'] += 1
+            if result['first_content_seconds'] is None:
+                result['first_content_seconds'] = elapsed
+            result['last_content_seconds'] = elapsed
+            if len(result['content_arrival_seconds']) < 100:
+                result['content_arrival_seconds'].append(elapsed)
+        finish = choice.get('finish_reason')
+        if finish is not None:
+            if not isinstance(finish, str) or result['finish_reason'] is not None:
+                invalid = True
+            result['finish_reason'] = _redact(str(finish), secrets)[:80]
+
+    try:
+        while True:
+            if time.perf_counter() - started > timeout:
+                raise TimeoutError
+            chunk = response.read1(4096)
+            if not chunk:
+                break
+            result['received_bytes'] += len(chunk)
+            if result['received_bytes'] > 1024 * 1024:
+                result['code'] = 'response_too_large'
+                return
+            pending += chunk
+            while b'\n' in pending:
+                line, pending = pending.split(b'\n', 1)
+                line = line.rstrip(b'\r')
+                if not line:
+                    dispatch()
+                elif line.startswith(b'data:'):
+                    value = line[5:]
+                    event.append(value[1:] if value.startswith(b' ') else value)
+        if result['content_length'] is not None and not result['transfer_encoding'] and result['received_bytes'] != result['content_length']:
+            raise IncompleteRead(b'')
+        result['response_complete'] = True
+        if pending or event:
+            invalid = True
+        if provider_error:
+            result['code'] = 'provider_error'
+        elif invalid:
+            result['code'] = 'invalid_sse'
+        elif not result['done_received'] or result['finish_reason'] is None:
+            result['code'] = 'stream_incomplete'
+        elif result['finish_reason'] != 'stop':
+            result['code'] = 'incomplete_completion'
+        elif not ''.join(content).strip():
+            result['code'] = 'empty_stream_content'
+        else:
+            result.update(ok=True, code='stream_success')
+    except IncompleteRead:
+        result.update(code='response_incomplete', error_type='IncompleteRead')
+    except (ValueError, UnicodeError):
+        result['code'] = 'invalid_sse'
+    finally:
+        result['assistant_text'] = _redact(''.join(content), secrets)[:1200]
+        result['hint'] = '到达时间仅供检查缓冲;单次短回答不能证明代理始终实时透传。成功需完整HTTP响应、非空正文、finish_reason=stop及[DONE]'
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="读取.env,以keep-alive POST测试DeepSeek SSE流")
+    parser.add_argument("--timeout", type=float, default=30, help="请求超时秒数,默认30")
+    parser.add_argument("--method", choices=("POST",), default="POST")
+    args = parser.parse_args()
+    if not 0 < args.timeout <= 120:
+        parser.error("--timeout须大于0且不超过120秒")
+    result = probe(timeout=args.timeout, method=args.method)
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0 if result["ok"] else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 315 - 0
scripts/test_dms_connection.py

@@ -0,0 +1,315 @@
+"""Read-only DMS metadata diagnosis using the project's production auth/config contract."""
+import argparse
+from contextlib import redirect_stderr
+import json
+import os
+from pathlib import Path
+import socket
+import ssl
+import sys
+import time
+from http.client import HTTPException
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode, quote, quote_plus, urlsplit
+from urllib.request import Request, build_opener, ProxyHandler
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+try:
+    from src.dms_schema import ALIASES, load_schema, names, expected_fields
+    from src.step5_session_analysis.dms import DmsClient, DmsError, NoRedirect, clean, load_dms_config
+    from src.step3_question_answer.dms_company import BASIC_FIELDS, HONOR_FIELDS
+except (OSError, ValueError, KeyError, TypeError, ImportError) as exc:
+    print(json.dumps({'ok': False, 'stage': 'project_import', 'error_type': type(exc).__name__,
+        'hint': '请将文件放入现有项目scripts目录,检查项目依赖和config/dms_schema.json;不输出配置正文。'}, ensure_ascii=False))
+    raise SystemExit(1) from None
+
+CONFIG_KEYS = {'DMS_BASE_URL', 'DMS_LOGIN_URL', 'DMS_USERNAME', 'DMS_PASSWORD', 'DMS_TOKEN',
+    'DMS_RECORDS_COLUMN_ID', 'DMS_SESSIONS_COLUMN_ID', 'DMS_DEMANDS_COLUMN_ID',
+    'DMS_STRICT_SCOPE', 'DMS_ALLOWED_SCOPES_JSON', 'DMS_SCAN_TIMEOUT_SECONDS', 'AUTH_PORT', 'DMS_PORT'}
+REQUIRED = {
+    'records': {'c_session_id', 'c_question', 'c_answer', 'c_created_at'},
+    'sessions': {'c_session_id', 'qpyszx', 'qyzt', 'rzqpyx', 'c_focus_points',
+        'c_settlement_intent_level', 'c_core_needs', 'jygjbm', 'summary',
+        'c_description', 'c_title', 'c_created_at', 'c_updated_at'},
+    'demands': {'c_session_id', 'c_word', 'c_created_at'},
+    'basic': {'c_' + key for key in BASIC_FIELDS} | {'c_area', 'c_revoke_info',
+        'c_original_name', 'c_designated_representative_list'},
+    'honor': {'c_' + key for key in HONOR_FIELDS} | {'c_credit_code'},
+}
+
+
+class DiagnosticTransport:
+    def __init__(self, timeout=20, no_env_proxy=False):
+        self.timeout = timeout
+        self.calls = []
+        self.secrets = set()
+        self.opener = build_opener(NoRedirect(), *([ProxyHandler({})] if no_env_proxy else []))
+
+    def __call__(self, url, form, headers):
+        self.secrets.update(str(v) for k, v in headers.items() if k.lower() == 'token' and v)
+        row = {'endpoint': url, 'method': 'POST', 'form_keys': sorted(form),
+               'token_header_present': bool(headers.get('token')),
+               'form': {key: '[REDACTED]' if key in {'userName', 'password'} else value
+                        for key, value in form.items()},
+               'headers': {key: '[REDACTED]' if key.lower() == 'token' else value
+                           for key, value in headers.items()}, 'timeout_seconds': self.timeout}
+        self.calls.append(row)
+        emit('request_start', row, self.secrets)
+        started = time.monotonic()
+        try:
+            request = Request(url, urlencode(form).encode(), headers)
+            with self.opener.open(request, timeout=self.timeout) as response:
+                row.update(http_status=response.status, content_type=response.headers.get('Content-Type', ''))
+                body = response.read(4_000_001)
+                row['received_bytes'] = len(body)
+                if len(body) > 4_000_000:
+                    raise DmsError('dms_response_too_large')
+                if getattr(response, 'length', None) not in (None, 0):
+                    raise DmsError('dms_response_incomplete')
+                try:
+                    envelope = json.loads(body)
+                except (ValueError, UnicodeError):
+                    raise DmsError('dms_invalid_json') from None
+                if isinstance(envelope, dict):
+                    code = envelope.get('code')
+                    row['provider_code'] = code if type(code) is int else 'invalid_or_missing'
+                    if 'userName' in form and 'password' in form and code == 200:
+                        token = envelope.get('message')
+                        if isinstance(token, str) and token:
+                            self.secrets.add(token)
+                return envelope
+        except HTTPError as exc:
+            row.update(http_status=exc.code, content_type=exc.headers.get('Content-Type', ''))
+            exc.close()
+            code = {401: 'dms_token_invalid', 403: 'dms_permission_denied'}.get(exc.code, 'dms_http_error')
+            row['error'] = code
+            raise DmsError(code) from None
+        except (URLError, OSError, HTTPException) as exc:
+            reason = exc.reason if isinstance(exc, URLError) else exc
+            code = ('dns_failed' if isinstance(reason, socket.gaierror) else
+                    'tls_failed' if isinstance(reason, ssl.SSLError) else
+                    'timeout' if isinstance(reason, TimeoutError) else
+                    'connection_refused' if isinstance(reason, ConnectionRefusedError) else
+                    'response_incomplete' if isinstance(reason, HTTPException) else 'connection_failed')
+            row.update(error=code, error_type=type(reason).__name__)
+            if isinstance(getattr(reason, 'errno', None), int):
+                row['errno'] = reason.errno
+            raise DmsError('dms_' + code) from None
+        except DmsError as exc:
+            row['error'] = str(exc)
+            raise
+        finally:
+            row['elapsed_seconds'] = round(time.monotonic() - started, 3)
+            emit('request_end', row, self.secrets)
+
+
+def field_differences(schema, alias, model):
+    raw = model.get('fieldList') if isinstance(model, dict) else None
+    try:
+        raw = json.loads(raw) if isinstance(raw, str) else raw
+        if not isinstance(raw, dict):
+            raise ValueError
+        raw = {k: json.loads(v) if isinstance(v, str) else v for k, v in raw.items()}
+        if not all(isinstance(v, dict) for v in raw.values()):
+            raise ValueError
+    except (ValueError, TypeError):
+        raise DmsError('dms_invalid_field_list') from None
+    spec = schema['columns'][alias]
+    expected = expected_fields(schema, alias)
+    rows = []
+    for logical, actual in spec['fields'].items():
+        found = raw.get(actual, {}).get('type')
+        rows.append({'logical_field': logical, 'actual_field': actual,
+                     'expected_type': expected[actual], 'actual_type': found if isinstance(found, str) else None,
+                     'status': 'missing' if actual not in raw else
+                     'type_mismatch' if found != expected[actual] else 'matched'})
+    missing = sorted(REQUIRED[alias] - set(spec['fields']))
+    return {'ok': not missing and all(row['status'] == 'matched' for row in rows),
+            'missing_local_mappings': missing, 'fields': rows,
+            'remote_field_count': len(raw),
+            'hint': '只核对配置字段;额外远端字段不判失败。不验证业务记录值或写权限。'}
+
+
+def inspect_column(client, alias):
+    config = client.config
+    spec = config.schema['columns'][alias]
+    identity = {'records': config.records_id, 'sessions': config.sessions_id,
+                'demands': config.demands_id}.get(alias, spec['id'])
+    report = {'ok': False, 'column_id': identity, 'expected_name': spec['name'], 'stage': 'column'}
+    try:
+        column = client.call('/column/getColumnById', {'id': identity})
+        if not isinstance(column, dict):
+            raise DmsError('dms_invalid_column')
+        report.update(actual_name=clean(column.get('title')), actual_column_type=column.get('type'))
+        if clean(column.get('title')) != spec['name'] or column.get('type') != 1:
+            raise DmsError('dms_column_mismatch')
+        report['stage'] = 'parent'
+        parent_id = column.get('parentId')
+        if type(parent_id) is not int or parent_id <= 0:
+            raise DmsError('dms_invalid_parent_id')
+        parent = client.call('/column/getColumnById', {'id': parent_id})
+        report.update(parent_id=parent_id, expected_parent=config.schema['parent'],
+                      actual_parent=clean(parent.get('title')) if isinstance(parent, dict) else None)
+        if report['actual_parent'] != config.schema['parent']:
+            raise DmsError('dms_parent_mismatch')
+        report['stage'] = 'model'
+        model_id = column.get('modelId')
+        if type(model_id) is not int or model_id <= 0:
+            raise DmsError('dms_invalid_model_id')
+        report['model_id'] = model_id
+        model = client.call('/model/getModelById', {'modelId': model_id})
+        report['stage'] = 'fields'
+        report.update(field_differences(config.schema, alias, model))
+        report['code'] = 'matched' if report['ok'] else 'dms_schema_mismatch'
+    except DmsError as exc:
+        report['code'] = str(exc)
+    return report
+
+
+def redact_report(value, secrets):
+    text = json.dumps(value, ensure_ascii=False, indent=2)
+    for secret in sorted({s for s in secrets if s}, key=len, reverse=True):
+        for variant in {secret, quote(secret, safe=''), quote_plus(secret),
+                        json.dumps(secret, ensure_ascii=False)[1:-1], json.dumps(secret)[1:-1]}:
+            text = text.replace(variant, '[REDACTED]')
+    return text
+
+
+def emit(event, details, secrets=()):
+    print(redact_report({'event': event, **details}, secrets), file=sys.stderr, flush=True)
+
+
+def validate_override(value):
+    if value is None:
+        return
+    try:
+        parts = urlsplit(value)
+        if (not value or any(c.isspace() or ord(c) < 33 for c in value) or '\\' in value
+                or parts.scheme not in ('http', 'https') or not parts.hostname
+                or parts.username is not None or parts.password is not None
+                or parts.query or parts.fragment or not parts.path or parts.path == '/'
+                or (parts.port is not None and not 1 <= parts.port <= 65535)):
+            raise ValueError
+    except (ValueError, TypeError):
+        raise DmsError('invalid_endpoint_override') from None
+
+
+def run(root, aliases, *, env_file_only=False, timeout=20, no_env_proxy=False,
+        api_url=None, login_url=None):
+    wire = DiagnosticTransport(timeout, no_env_proxy)
+    report = {'ok': False, 'env_file_exists': (root / '.env').is_file(),
+              'schema_file_exists': (root / 'config/dms_schema.json').is_file(),
+              'env_file_only': env_file_only,
+              'environment_override_keys': [] if env_file_only else sorted(CONFIG_KEYS & os.environ.keys()),
+              'environment_proxy_keys': sorted(k for k in os.environ if k.lower() in
+                  {'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'}),
+              'use_environment_proxy': not no_env_proxy, 'checks': {}, 'requests': wire.calls}
+    try:
+        report['stage'] = 'endpoint_arguments'
+        validate_override(api_url)
+        validate_override(login_url)
+        emit('local_configuration', {'env_file': str(root / '.env'),
+            'schema_file': str(root / 'config/dms_schema.json'),
+            'env_file_only': env_file_only, 'selected_columns': list(aliases),
+            'environment_override_keys': report['environment_override_keys']})
+        report['stage'] = 'local_schema'
+        schema = load_schema(root / 'config/dms_schema.json')
+        report['stage'] = 'configuration_and_scope'
+        config = load_dms_config(root / '.env', environ={} if env_file_only else None,
+            schema_path=root / 'config/dms_schema.json', scope_names=names(schema, aliases))
+        wire.secrets.update(x for x in (config.token, config.username, config.password) if x)
+        client = DmsClient(config, transport=wire)
+        # Instance-only overrides: compatible with the old DmsConfig without login_url.
+        # Both call() retries and _login() use these attributes, never write configuration.
+        if api_url is not None:
+            client.api = api_url.rstrip('/')
+        if login_url is not None:
+            client.login_url = login_url
+        report.update(api_endpoint=client.api, login_endpoint=client.login_url,
+                      api_url_source='command_line' if api_url is not None else 'configuration',
+                      login_url_source='command_line' if login_url is not None else 'configuration',
+                      strict_scope=config.strict, token_configured=bool(config.token),
+                      login_credentials_configured=bool(config.username and config.password))
+        emit('connection_parameters', {'base_url': config.base_url, 'api_endpoint': client.api,
+            'login_endpoint': client.login_url, 'AUTH_PORT': config.auth_port, 'DMS_PORT': config.dms_port,
+            'api_url_source': report['api_url_source'],
+            'login_url_source': ('command_line' if login_url is not None else
+                                 'DMS_LOGIN_URL' if getattr(config, 'login_url', '') else 'default_route'),
+            'timeout_seconds': timeout, 'use_environment_proxy': not no_env_proxy,
+            'environment_proxy_keys': report['environment_proxy_keys'],
+            'strict_scope': config.strict, 'allowed_scope_names': list(config.scope_names),
+            'token': '[REDACTED]' if config.token else '[NOT CONFIGURED]',
+            'username': '[REDACTED]' if config.username else '[NOT CONFIGURED]',
+            'password': '[REDACTED]' if config.password else '[NOT CONFIGURED]'}, wire.secrets)
+        report['stage'] = 'authentication'
+        if not client.token:
+            client._login()  # Same proven login contract; no content writes.
+        for alias in aliases:
+            emit('column_start', {'alias': alias, 'expected_name': schema['columns'][alias]['name'],
+                'expected_parent': schema['parent'], 'column_id': {'records': config.records_id,
+                'sessions': config.sessions_id, 'demands': config.demands_id}.get(alias, schema['columns'][alias]['id'])}, wire.secrets)
+            start = len(wire.calls)
+            report['checks'][alias] = inspect_column(client, alias)
+            report['checks'][alias]['request_indexes'] = list(range(start, len(wire.calls)))
+            emit('column_end', {'alias': alias, **report['checks'][alias]}, wire.secrets)
+        report.update(stage='finished', ok=all(row['ok'] for row in report['checks'].values()))
+    except DmsError as exc:
+        report['code'] = str(exc)
+    except (ValueError, KeyError, TypeError, OSError) as exc:
+        report.update(code='invalid_local_configuration', error_type=type(exc).__name__)
+    return report, wire.secrets
+
+
+def compact_report(report, selected_count):
+    checks = report.get('checks', {})
+    failed = {alias: row for alias, row in checks.items() if not row['ok']}
+    output = {'ok': report['ok'], 'checked_columns': len(checks),
+              'passed_columns': len(checks) - len(failed), 'failed_columns': len(failed),
+              'not_checked_columns': selected_count - len(checks)}
+    if report['ok']:
+        return output
+    for key in ('stage', 'code', 'error_type', 'api_endpoint', 'login_endpoint'):
+        if key in report:
+            output[key] = report[key]
+    output['failed_checks'] = {}
+    indexes = set()
+    for alias, row in failed.items():
+        details = {k: v for k, v in row.items() if k not in ('fields', 'hint', 'request_indexes', 'remote_field_count')}
+        if 'fields' in row:
+            details['fields'] = [field for field in row['fields'] if field['status'] != 'matched']
+        output['failed_checks'][alias] = details
+        if row.get('request_indexes'):
+            indexes.add(row['request_indexes'][-1])
+    requests = report.get('requests', [])
+    indexes.update(i for i, row in enumerate(requests) if row.get('error') or
+                   ('provider_code' in row and row['provider_code'] != 200))
+    if not checks and requests:
+        indexes.add(len(requests) - 1)
+    output['related_requests'] = [{'request_index': i, **requests[i]} for i in sorted(indexes)]
+    return output
+
+
+def main():
+    parser = argparse.ArgumentParser(description='DMS只读连接、鉴权、五栏目和字段类型诊断;不读取业务记录')
+    parser.add_argument('--env-file-only', action='store_true')
+    parser.add_argument('--column', choices=['all', *ALIASES], default='all')
+    parser.add_argument('--timeout', type=float, default=20)
+    parser.add_argument('--verbose', action='store_true', help='显示全部请求过程和正常字段;默认只显示汇总及异常')
+    parser.add_argument('--api-url', help='仅本次诊断覆盖完整DMS数据入口,不受DMS_PORT覆盖')
+    parser.add_argument('--login-url', help='仅本次诊断覆盖完整登录地址,不受AUTH_PORT覆盖')
+    parser.add_argument('--no-env-proxy', action='store_true', help='仅用于对比:禁用系统HTTP代理,默认与正式客户端一致')
+    args = parser.parse_args()
+    if not 0 < args.timeout <= 120:
+        parser.error('timeout须在0到120秒之间')
+    aliases = ALIASES if args.column == 'all' else (args.column,)
+    with open(os.devnull, 'w') as sink, redirect_stderr(sys.stderr if args.verbose else sink):
+        report, secrets = run(ROOT, aliases,
+            env_file_only=args.env_file_only, timeout=args.timeout, no_env_proxy=args.no_env_proxy,
+            api_url=args.api_url, login_url=args.login_url)
+    print(redact_report(report if args.verbose else compact_report(report, len(aliases)), secrets))
+    return 0 if report['ok'] else 1
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 256 - 0
scripts/test_qcc_proxy.py

@@ -0,0 +1,256 @@
+"""Standalone QCC GET proxy diagnostic. No dependencies or company data output."""
+import argparse
+from datetime import datetime, timezone
+from email.utils import parsedate_to_datetime
+import hashlib
+import html
+from http.client import HTTPConnection, HTTPSConnection, IncompleteRead
+import json
+import os
+import re
+from pathlib import Path
+import time
+from urllib.parse import parse_qsl, quote, quote_plus, urlencode, urlsplit
+
+URL_KEYS = {'search': 'QCC_SEARCH_URL', 'basic': 'QCC_BASIC_URL', 'honor': 'QCC_HONOR_URL'}
+CONFIG_KEYS = set(URL_KEYS.values()) | {'QCC_KEY', 'QCC_SECRET_KEY', 'QCC_TIMEOUT_SECONDS'}
+
+
+def redact(text, secrets):
+    text = html.unescape(str(text))
+    for secret in sorted({s for s in secrets if s}, key=len, reverse=True):
+        variants = {secret, quote(secret, safe=''), quote_plus(secret),
+                    json.dumps(secret, ensure_ascii=True)[1:-1],
+                    json.dumps(secret, ensure_ascii=False)[1:-1]}
+        for variant in sorted(variants, key=len, reverse=True):
+            text = re.sub(re.escape(variant), '[REDACTED]', text, flags=re.IGNORECASE)
+    text = re.sub(r'(?i)((?:proxyToken|QCC_SECRET_KEY|QCC_KEY|Token|searchKey|keyword|key)[\"\s\x27]*[:=][\"\s\x27]*)[^&\s\"\x27<>]+', r'\1[REDACTED]', text)
+    return text
+
+
+def error_body(body, secrets):
+    text = body.decode('utf-8', errors='replace')
+    try:
+        value = json.loads(text)
+        def sanitize(item):
+            if isinstance(item, dict):
+                return {k: '[OMITTED]' if k.lower() in {'result', 'data'} else sanitize(v)
+                        for k, v in item.items()}
+            if isinstance(item, list):
+                return [sanitize(v) for v in item]
+            return item
+        text = json.dumps(sanitize(value), ensure_ascii=False)
+    except (ValueError, RecursionError):
+        pass
+    text = redact(text, secrets)
+    return {'error_body_preview': text[:2000], 'error_body_truncated': len(text) > 2000}
+
+
+def load_settings(path, *, env_file_only=False):
+    values = {}
+    if path.is_file():
+        for line in path.read_text(encoding='utf-8-sig').splitlines():
+            line = line.strip()
+            if line.startswith('export '):
+                line = line[7:]
+            key, sep, value = line.partition('=')
+            if not sep or key.strip() not in CONFIG_KEYS:
+                continue
+            value = value.strip()
+            if value.startswith(('"', "'")):
+                end = value.find(value[0], 1)
+                if end < 0 or (value[end + 1:].strip() and not value[end + 1:].strip().startswith('#')):
+                    raise ValueError('invalid_env_file')
+                value = value[1:end]
+            else:
+                value = value.split(' #', 1)[0].strip()
+            values[key.strip()] = value
+    if not env_file_only:
+        values.update({k: os.environ[k] for k in CONFIG_KEYS if k in os.environ})
+    return values
+
+
+def clock_diagnostics(timestamp, date_values, elapsed):
+    """Date is an intermediary's clock, not a trusted time synchronization source."""
+    result = {'request_time_utc': datetime.fromtimestamp(int(timestamp), timezone.utc).isoformat()}
+    if len(date_values) != 1:
+        return result
+    try:
+        remote = parsedate_to_datetime(date_values[0])
+        if remote.tzinfo is None:
+            return result
+        delta = remote.timestamp() - int(timestamp)
+    except (TypeError, ValueError, OverflowError):
+        return result
+    result.update(response_date_minus_request_seconds=round(delta, 3),
+                  clock_skew_suspected=abs(delta) > elapsed + 60)
+    return result
+
+
+def validate_payload(api, payload):
+    """Check the current project's QCC response contracts, never dump results."""
+    if not isinstance(payload, dict):
+        return {'ok': False, 'code': 'invalid_response_schema'}
+    status = str(payload.get('Status', ''))
+    details = {'provider_status': status if status.isascii() and status.isdigit() and len(status) <= 8 else 'unknown'}
+    result = payload.get('Result')
+    if api == 'search' and status == '201' and result in (None, []):
+        return {'ok': True, 'code': 'no_matches', 'records': 0, **details}
+    if status != '200':
+        return {'ok': False, 'code': 'provider_failure', **details}
+    if api == 'search':
+        valid = isinstance(result, list) and all(isinstance(r, dict) and
+            isinstance(r.get('Name'), str) and bool(r['Name'].strip()) and
+            isinstance(r.get('KeyNo'), str) and bool(r['KeyNo'].strip()) for r in result)
+        count = len(result) if valid else 0
+    elif api == 'basic':
+        valid = isinstance(result, dict) and bool(result) and all(
+            isinstance(result.get(k), str) and bool(result[k].strip()) for k in ('Name', 'KeyNo'))
+        count = int(valid)
+    else:
+        valid = isinstance(result, dict) and type(result.get('VerifyResult')) is int and result['VerifyResult'] in (0, 1)
+        rows = result.get('Data') if valid else None
+        if valid and result['VerifyResult'] == 0 and rows is None:
+            rows = []
+        valid = valid and isinstance(rows, list) and all(isinstance(r, dict) for r in rows)
+        valid = valid and not (result['VerifyResult'] == 0 and rows)
+        count = len(rows) if valid else 0
+    if not valid:
+        return {'ok': False, 'code': 'invalid_result', **details}
+    return {'ok': True, 'code': 'usable' if count else 'no_records', 'records': count, **details}
+
+
+def probe(api, values, company, timeout, mode='proxy'):
+    report = {'ok': False, 'method': 'GET', 'transport': 'http.client',
+              'request_connection': 'keep-alive', 'use_environment_proxy': False,
+              'url_setting': URL_KEYS[api], 'mode': mode,
+              'page_scope': 'first_page' if api != 'basic' else 'single_result'}
+    if mode not in {'direct', 'proxy'}:
+        return {**report, 'code': 'invalid_mode'}
+    key, secret = values.get('QCC_KEY', ''), values.get('QCC_SECRET_KEY', '')
+    if not key or not secret:
+        return {**report, 'code': 'missing_qcc_credentials'}
+    params = ({'searchKey': company, 'pageIndex': 1} if api == 'search' else
+              {'keyword': company} if api == 'basic' else
+              {'searchKey': company, 'pageSize': 20, 'pageIndex': 1})
+    try:
+        url = values.get(URL_KEYS[api], '')
+        if not url or any(c.isspace() for c in url) or '\\' in url:
+            raise ValueError
+        parts = urlsplit(url)
+        if (parts.scheme not in {'http', 'https'} or not parts.hostname or
+                parts.username is not None or parts.password is not None or parts.fragment):
+            raise ValueError
+        _ = parts.port
+        query = parse_qsl(parts.query, keep_blank_values=True)
+    except ValueError:
+        return {**report, 'code': 'invalid_proxy_url', 'hint': 'URL须单行HTTP(S)地址,不含空格、反斜杠或用户密码'}
+    names = [k for k, _ in query]
+    if mode == 'direct' and query:
+        return {**report, 'code': 'direct_url_must_not_have_query'}
+    if mode == 'proxy' and (names.count('servertype') != 1 or names.count('proxyToken') != 1
+            or not dict(query).get('servertype') or not dict(query).get('proxyToken')):
+        return {**report, 'code': 'missing_proxy_params', 'hint': '须使用servertype和proxyToken,区分大小写,以&分隔、以=赋值'}
+    if any(k in {'key', *params} for k in names):
+        return {**report, 'code': 'duplicate_business_params'}
+    # Preserve the gateway query bytes and routing value; do not guess/correct its spelling.
+    target = (parts.path or '/') + '?' + (parts.query + '&' if parts.query else '') + urlencode({'key': key, **params})
+    timestamp = str(int(time.time()))
+    token = hashlib.md5((key + timestamp + secret).encode()).hexdigest().upper()
+    secrets = [key, secret, token, company, *[v for k, v in query if k != 'servertype']]
+    report.update(endpoint=redact(f'{parts.scheme}://{parts.netloc}{parts.path}', secrets),
+                  servertype=redact(dict(query).get('servertype', ''), secrets),
+                  query_keys=sorted(set(names) | {'key', *params}), request_timespan=timestamp)
+
+    def failure(code, body=b'', **details):
+        report.update(code=code, **details)
+        report.update(error_body(body, secrets))
+        return report
+
+    headers = {'Token': token, 'Timespan': timestamp, 'Accept': '*/*', 'Connection': 'keep-alive'}
+    connection = None
+    started = time.perf_counter()
+    try:
+        connection = (HTTPSConnection if parts.scheme == 'https' else HTTPConnection)(parts.hostname, parts.port, timeout=timeout)
+        connection.request('GET', target, headers=headers)
+        with connection.getresponse() as response:
+            report.update(http_status=response.status, response_complete=False)
+            report.update(clock_diagnostics(timestamp, response.headers.get_all('Date', []),
+                                            time.perf_counter() - started))
+            report['response_headers'] = {
+                name: [redact(value, secrets)[:500] for value in response.headers.get_all(name, [])][:4]
+                for name in ('Content-Type', 'Server', 'Date', 'Content-Length', 'Transfer-Encoding',
+                             'Connection', 'X-Request-ID', 'X-Correlation-ID', 'X-DS-Trace-ID', 'EO-LOG-UUID')
+                if response.headers.get_all(name)
+            }
+            try:
+                body = response.read(4_000_001)
+            except IncompleteRead as exc:
+                report['received_bytes'] = len(exc.partial)
+                return failure('response_incomplete', exc.partial, error_type='IncompleteRead')
+            report['received_bytes'] = len(body)
+            length = response.headers.get('Content-Length', '')
+            if not response.headers.get('Transfer-Encoding') and length.isascii() and length.isdigit() and len(body) < int(length):
+                return failure('response_incomplete', body)
+            if len(body) > 4_000_000:
+                return failure('response_too_large')
+            report['response_complete'] = True
+            if not 200 <= response.status < 300:
+                return failure('http_error', body)
+        try:
+            payload = json.loads(body)
+        except (ValueError, UnicodeError):
+            return failure('invalid_response_json', body)
+        report.update(validate_payload(api, payload))
+        if not report['ok']:
+            report.update(error_body(body, secrets))
+            if report.get('provider_status') == '115':
+                report['hint'] = ('本次Token已用运行机器当前Unix秒数实时生成。'
+                    '请核对机器UTC时间、QCC_KEY与QCC_SECRET_KEY配对及环境变量覆盖,'
+                    '并检查代理透传Token/Timespan/key。Date偏差仅供诊断,'
+                    '可能来自代理或缓存;60秒是诊断阈值,不是企查查签名有效期。')
+    except TimeoutError:
+        report['code'] = 'timeout'
+    except OSError as exc:
+        report.update(code='connection_failed', error_type=type(exc).__name__)
+    except Exception as exc:
+        report.update(code='request_failed', error_type=type(exc).__name__)
+    finally:
+        if connection is not None:
+            connection.close()
+        report['elapsed_seconds'] = round(time.perf_counter() - started, 3)
+    return report
+
+
+def main():
+    parser = argparse.ArgumentParser(description='企查查三接口代理GET诊断,不输出密钥或公司正文')
+    parser.add_argument('--company', required=True, help='要测试的准确企业全称')
+    parser.add_argument('--api', choices=['all', *URL_KEYS], default='all')
+    parser.add_argument('--mode', choices=['proxy', 'direct'], default='proxy',
+                        help='默认proxy要求代理参数;direct使用无query的直连URL,传输和签名不变')
+    parser.add_argument('--timeout', type=float, help='每次请求超时秒数,默认QCC_TIMEOUT_SECONDS或20')
+    parser.add_argument('--env-file-only', action='store_true', help='仅读取项目.env,忽略同名环境变量')
+    args = parser.parse_args()
+    if not args.company.strip():
+        parser.error('--company不能为空')
+    path = Path(__file__).resolve().parents[1] / '.env'
+    try:
+        settings = load_settings(path, env_file_only=args.env_file_only)
+        timeout = args.timeout if args.timeout is not None else float(settings.get('QCC_TIMEOUT_SECONDS', '20'))
+        if not 0 < timeout <= 120:
+            raise ValueError
+    except (OSError, ValueError):
+        print(json.dumps({'ok': False, 'code': 'invalid_env_or_timeout'}))
+        return 1
+    checks = {api: probe(api, settings, args.company.strip(), timeout, mode=args.mode)
+              for api in (URL_KEYS if args.api == 'all' else [args.api])}
+    ok = all(check['ok'] for check in checks.values())
+    print(json.dumps({'ok': ok, 'env_file_exists': path.is_file(),
+                      'env_file_only': args.env_file_only,
+                      'environment_override_keys': [] if args.env_file_only else sorted(CONFIG_KEYS & os.environ.keys()),
+                      'checks': checks}, ensure_ascii=False, indent=2))
+    return 0 if ok else 1
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 276 - 0
scripts/tests/test_deepseek_proxy.py

@@ -0,0 +1,276 @@
+import contextlib
+import importlib.util
+import io
+import json
+import os
+import threading
+import tempfile
+import unittest
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from unittest.mock import patch
+
+
+spec = importlib.util.spec_from_file_location(
+    "deepseek_proxy_probe", Path(__file__).resolve().parents[1] / "test_deepseek_proxy.py"
+)
+probe_module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(probe_module)
+
+
+class DeepSeekProxyTests(unittest.TestCase):
+    def setUp(self):
+        self.requests = []
+        self.status = 200
+        self.body = json.dumps({"choices": [{"message": {"content": "OK"}}]}).encode()
+        self.headers = {"Content-Type": "application/json"}
+        test = self
+
+        class Handler(BaseHTTPRequestHandler):
+            protocol_version = 'HTTP/1.1'
+
+            def send_body(self):
+                self.send_response(test.status)
+                for key, value in test.headers.items():
+                    self.send_header(key, value)
+                if 'Transfer-Encoding' not in test.headers and 'Content-Length' not in test.headers:
+                    self.send_header('Content-Length', str(len(test.body)))
+                else:
+                    # Broken framing cases deliberately terminate the response.
+                    self.close_connection = True
+                self.end_headers()
+                self.wfile.write(test.body)
+
+            def do_GET(self):
+                test.requests.append({
+                    "path": self.path, "method": "GET",
+                    "authorization": self.headers.get("Authorization"),
+                    "payload": None, "content_length": self.headers.get("Content-Length"),
+                })
+                self.send_body()
+
+            def do_POST(self):
+                test.requests.append({
+                    "path": self.path, "method": "POST",
+                    "connection": self.headers.get("Connection"),
+                    "authorization": self.headers.get("Authorization"),
+                    "payload": json.loads(self.rfile.read(int(self.headers["Content-Length"]))),
+                })
+                self.send_body()
+
+            def log_message(self, *_args):
+                pass
+
+        self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+        self.thread.start()
+        self.url = f"http://127.0.0.1:{self.server.server_port}/proxy/?servertype=deepseek&proxyToken=fake%2Btoken%26value"
+        self.environment = {
+            "DEEPSEEK_BASE_URL": self.url, "DEEPSEEK_API_KEY": "fake-api-key",
+            "DEEPSEEK_MODEL": "test-model", "HTTP_PROXY": "http://invalid.example:1",
+        }
+
+    def tearDown(self):
+        self.server.shutdown()
+        self.server.server_close()
+        self.thread.join(timeout=2)
+
+    def run_probe(self, method=None):
+        with patch.dict(os.environ, self.environment), patch.object(probe_module, "load_env_values", return_value={
+            "DEEPSEEK_BASE_URL": "http://ignored.example/proxy/?serverType=deepseek&proxyToken=ignored",
+            "DEEPSEEK_API_KEY": "ignored", "DEEPSEEK_MODEL": "ignored"
+        }) as load:
+            result = probe_module.probe(timeout=2, **({"method": method} if method else {}))
+            self.assertEqual(load.call_args.args[0], Path(__file__).resolve().parents[2] / ".env")
+        return result
+
+    def test_actual_http_post_preserves_fixed_url_and_payload(self):
+        result = self.run_probe()
+        self.assertTrue(result["ok"], result)
+        request = self.requests[0]
+        self.assertEqual(request["path"], "/proxy/?servertype=deepseek&proxyToken=fake%2Btoken%26value",
+                         "固定代理入口不可追加/chat/completions或修改token")
+        self.assertEqual(request["authorization"], "Bearer fake-api-key")
+        self.assertEqual(request["payload"]["model"], "test-model")
+        self.assertEqual(request["payload"]["messages"], [{"role": "user", "content": "1+1等于多少"}])
+        self.assertEqual(request["connection"], "keep-alive")
+        self.assertEqual(result["transport"], "http.client")
+        self.assertFalse(request["payload"]["stream"])
+        self.assertTrue(result["environment_proxy_configured"])
+        self.assertNotIn("fake+token&value", json.dumps(result))
+        self.assertEqual(len(self.requests), 1)
+
+    def test_get_sends_exact_gateway_url_without_chat_body(self):
+        result = self.run_probe("GET")
+        self.assertEqual(result["method"], "GET")
+        self.assertFalse(result["chat_payload_sent"])
+        self.assertTrue(result["http_ok"])
+        self.assertEqual(self.requests[0]["path"],
+                         "/proxy/?servertype=deepseek&proxyToken=fake%2Btoken%26value")
+        self.assertEqual(self.requests[0]["method"], "GET")
+        self.assertIsNone(self.requests[0]["content_length"])
+        self.assertIsNone(self.requests[0]["payload"])
+        self.assertEqual(len(self.requests), 1)
+
+    def test_complete_chat_path_removes_trailing_slash_without_changing_query(self):
+        self.environment['DEEPSEEK_BASE_URL'] = self.url.replace('/proxy/?', '/proxy/chat/completions/?')
+        result = self.run_probe()
+        self.assertTrue(result['ok'])
+        self.assertEqual(self.requests[0]['path'],
+                         '/proxy/chat/completions?servertype=deepseek&proxyToken=fake%2Btoken%26value')
+        self.assertTrue(result['endpoint'].endswith('/proxy/chat/completions'))
+
+    def test_get_http_success_is_not_enough_to_verify_chat(self):
+        self.body = b"gateway connected"
+        result = self.run_probe("GET")
+        self.assertTrue(result["http_ok"])
+        self.assertFalse(result["ok"])
+        self.assertEqual(result["code"], "unexpected_response")
+
+    def test_gateway_auth_failure_redacts_echoed_credentials(self):
+        self.status = 401
+        self.body = b"Authentication Fails (governor) fake-api-key fake+token&value fake%2Btoken%26value"
+        result = self.run_probe()
+        self.assertEqual(result["code"], "proxy_authentication_failed")
+        self.assertEqual(result["http_status"], 401)
+        for secret in ("fake-api-key", "fake+token&value", "fake%2Btoken%26value"):
+            self.assertNotIn(secret, json.dumps(result))
+        self.assertEqual(len(self.requests), 1, "诊断只发一次请求,不自动重试")
+
+    def test_http_200_non_chat_response_is_failure(self):
+        for body in (b"gateway reachable", b'{"error":{"message":"denied"}}', b'{"choices":[]}'):
+            with self.subTest(body=body):
+                self.body = body
+                result = self.run_probe()
+                self.assertFalse(result["ok"])
+                self.assertEqual(result["code"], "unexpected_response")
+
+    def set_unfinished_chunked_body(self, body):
+        self.headers["Transfer-Encoding"] = "chunked"
+        # A complete data chunk followed by a closed connection, without the
+        # mandatory zero-sized final chunk. This reproduces the gateway symptom.
+        self.body = f"{len(body):X}\r\n".encode() + body + b"\r\n"
+
+    def test_http_500_incomplete_read_preserves_redacted_error_body(self):
+        self.status = 500
+        error = b'{"error":"upstream failed fake-api-key fake+token&value fake%2Btoken%26value"}'
+        self.set_unfinished_chunked_body(error)
+        result = self.run_probe()
+        self.assertEqual(result["http_status"], 500)
+        self.assertEqual(result["code"], "http_error")
+        self.assertEqual(result["error_type"], "IncompleteRead")
+        self.assertFalse(result["response_complete"])
+        self.assertEqual(result["received_bytes"], len(error))
+        self.assertIn("upstream failed", result["response_preview"])
+        for secret in ("fake-api-key", "fake+token&value", "fake%2Btoken%26value"):
+            self.assertNotIn(secret, json.dumps(result))
+        self.assertEqual(len(self.requests), 1)
+
+    def test_incomplete_http_200_valid_json_cannot_report_chat_success(self):
+        self.set_unfinished_chunked_body(self.body)
+        result = self.run_probe()
+        self.assertFalse(result["ok"], "响应体JSON合法也不能把不完整HTTP响应报成功")
+        self.assertEqual(result["code"], "response_incomplete")
+        self.assertIn("choices", result["response_preview"])
+
+    def test_short_content_length_error_is_visible(self):
+        self.status = 500
+        self.body = b'{"error":"short body"}'
+        self.headers["Content-Length"] = str(len(self.body) + 20)
+        result = self.run_probe()
+        self.assertEqual(result["code"], "http_error")
+        self.assertEqual(result["error_type"], "ContentLengthMismatch")
+        self.assertFalse(result["response_complete"])
+        self.assertIn("short body", result["response_preview"])
+        self.assertEqual(result["content_length"] - result["received_bytes"], 20)
+
+    def test_redirect_is_reported_without_second_request(self):
+        self.status = 302
+        self.headers["Location"] = f"http://127.0.0.1:{self.server.server_port}/other"
+        result = self.run_probe()
+        self.assertEqual(result["code"], "http_error")
+        self.assertEqual(len(self.requests), 1)
+
+    def test_missing_api_key_can_probe_gateway_without_fake_bearer(self):
+        self.environment["DEEPSEEK_API_KEY"] = ""
+        result = self.run_probe()
+        self.assertFalse(result["api_key_configured"])
+        self.assertIsNone(self.requests[0]["authorization"])
+
+    def test_invalid_and_wrong_case_params_do_not_send(self):
+        for url, code in ((self.url.replace("servertype", "serverType"), "missing_proxy_params"),
+                          (self.url.replace("proxyToken", "proxytoken"), "missing_proxy_params"),
+                          ("http://gateway.example\n:123/proxy", "invalid_base_url"),
+                          ("http://gateway.example:bad/proxy", "invalid_base_url")):
+            with self.subTest(code=code, url=url):
+                self.environment["DEEPSEEK_BASE_URL"] = url
+                self.assertEqual(self.run_probe()["code"], code)
+        self.assertEqual(self.requests, [])
+
+    def test_connection_failure_does_not_echo_full_url(self):
+        with patch.object(probe_module, "HTTPConnection") as build:
+            build.return_value.request.side_effect = ConnectionRefusedError(self.url)
+            result = self.run_probe()
+            build.return_value.close.assert_called_once()
+        self.assertEqual(result["code"], "connection_failed")
+        self.assertNotIn("fake", json.dumps(result))
+
+    def test_https_timeout_closes_connection_without_secret_echo(self):
+        self.environment['DEEPSEEK_BASE_URL'] = self.url.replace('http:', 'https:')
+        with patch.object(probe_module, 'HTTPSConnection') as build:
+            build.return_value.getresponse.side_effect = TimeoutError(self.url)
+            result = self.run_probe()
+            self.assertEqual(result['code'], 'timeout')
+            build.return_value.close.assert_called_once()
+            self.assertEqual(build.call_args.kwargs['timeout'], 2)
+        self.assertNotIn('fake', json.dumps(result))
+
+    def test_complete_chunked_response(self):
+        self.set_unfinished_chunked_body(self.body)
+        self.body += b'0\r\n\r\n'
+        result = self.run_probe()
+        self.assertTrue(result['ok'], result)
+        self.assertTrue(result['response_complete'])
+
+    def test_cli_exit_codes_and_safe_json(self):
+        for ok in (True, False):
+            output = io.StringIO()
+            with patch.object(probe_module, "probe", return_value={"ok": ok}), patch.object(
+                probe_module.sys, "argv", ["test_deepseek_proxy.py"]
+            ), contextlib.redirect_stdout(output):
+                self.assertEqual(probe_module.main(), 0 if ok else 1)
+            self.assertEqual(json.loads(output.getvalue()), {"ok": ok})
+
+    def test_cli_defaults_to_post_and_method_can_be_selected(self):
+        for arguments, method in (([], "POST"), (["--method", "GET"], "GET"),
+                                  (["--method", "POST"], "POST")):
+            with patch.object(probe_module, "probe", return_value={"ok": True}) as probe, patch.object(
+                probe_module.sys, "argv", ["test_deepseek_proxy.py", *arguments]
+            ), contextlib.redirect_stdout(io.StringIO()):
+                self.assertEqual(probe_module.main(), 0)
+                self.assertEqual(probe.call_args.kwargs["method"], method)
+
+
+class EnvReadTests(unittest.TestCase):
+    def test_synthetic_env_preserves_case_quoting_and_last_definition(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            env_path = Path(temporary) / ".env"
+            env_path.write_text(
+                '# synthetic settings\nDEEPSEEK_BASE_URL=http://old.example\n'
+                'export DEEPSEEK_BASE_URL="http://gateway.example/proxy/?servertype=deepseek&proxyToken=fake-token" # comment\n'
+                "DEEPSEEK_API_KEY='fake-key'\nDEEPSEEK_MODEL=deepseek-flash # comment\nUNRELATED=ignored\n",
+                encoding="utf-8-sig",
+            )
+            values = probe_module.load_env_values(env_path)
+            self.assertEqual(values["DEEPSEEK_BASE_URL"],
+                             "http://gateway.example/proxy/?servertype=deepseek&proxyToken=fake-token")
+            self.assertEqual(values["DEEPSEEK_API_KEY"], "fake-key")
+            self.assertEqual(values["DEEPSEEK_MODEL"], "deepseek-flash")
+            self.assertEqual(len(values), 3)
+            env_path.write_text('DEEPSEEK_BASE_URL="unclosed', encoding="utf-8")
+            with self.assertRaisesRegex(ValueError, "invalid_env_quoting"):
+                probe_module.load_env_values(env_path)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 94 - 0
scripts/tests/test_deepseek_proxy_stream.py

@@ -0,0 +1,94 @@
+import importlib.util
+import json
+import os
+from pathlib import Path
+import time
+import unittest
+from unittest.mock import patch
+
+from scripts.tests import test_deepseek_proxy as ordinary
+
+spec = importlib.util.spec_from_file_location('stream_probe', Path(__file__).resolve().parents[1] / 'test_deepseek_proxy_stream.py')
+probe = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(probe)
+
+
+def frame(text=None, finish=None):
+    return ('data: ' + json.dumps({'choices': [{'index': 0, 'delta': {'content': text},
+                'finish_reason': finish}]}, ensure_ascii=False) + '\r\n\r\n').encode()
+
+
+class StreamProbeTests(unittest.TestCase):
+    setUp = ordinary.DeepSeekProxyTests.setUp
+    tearDown = ordinary.DeepSeekProxyTests.tearDown
+
+    def run_probe(self):
+        with patch.dict(os.environ, self.environment), patch.object(probe, 'load_env_values', return_value={}):
+            return probe.probe(timeout=2)
+
+    def test_real_chunked_sse_keepalive(self):
+        self.headers = {'Content-Type': 'text/event-stream; charset=utf-8', 'Transfer-Encoding': 'chunked'}
+        frames = [b': heartbeat\r\n\r\n', frame('你'), frame('好'), frame(finish='stop'),
+                  b'data: {"choices":[],"usage":{}}\r\n\r\n', b'data: [DONE]\r\n\r\n']
+        self.body = b''.join(f'{len(x):x}\r\n'.encode() + x + b'\r\n' for x in frames) + b'0\r\n\r\n'
+        result = self.run_probe()
+        self.assertTrue(result['ok'], result)
+        self.assertTrue(result['done_received'])
+        self.assertTrue(result['response_complete'])
+        self.assertEqual(result['assistant_text'], '你好')
+        self.assertEqual(result['content_chunks'], 2)
+        self.assertEqual(self.requests[0]['connection'], 'keep-alive')
+        self.assertTrue(self.requests[0]['payload']['stream'])
+        self.assertEqual(self.requests[0]['path'], '/proxy/?servertype=deepseek&proxyToken=fake%2Btoken%26value')
+
+    def test_failure_matrix(self):
+        self.headers = {'Content-Type': 'text/event-stream'}
+        for body, code in (
+            (frame('x', 'stop'), 'stream_incomplete'),
+            (frame('x') + b'data: [DONE]\n\n', 'stream_incomplete'),
+            (frame(None, 'stop') + b'data: [DONE]\n\n', 'empty_stream_content'),
+            (frame('x', 'length') + b'data: [DONE]\n\n', 'incomplete_completion'),
+            (b'data: {broken}\n\n', 'invalid_sse'),
+            (b'data: {"error":"fake-api-key"}\n\n', 'provider_error'),
+        ):
+            with self.subTest(code=code):
+                self.body = body
+                result = self.run_probe()
+                self.assertFalse(result['ok'])
+                self.assertEqual(result['code'], code)
+                self.assertNotIn('fake-api-key', json.dumps(result))
+
+    def test_done_cannot_hide_broken_http_chunking(self):
+        self.headers = {'Content-Type': 'text/event-stream', 'Transfer-Encoding': 'chunked'}
+        body = frame('x', 'stop') + b'data: [DONE]\n\n'
+        self.body = f'{len(body):x}\r\n'.encode() + body + b'\r\n'
+        result = self.run_probe()
+        self.assertFalse(result['ok'])
+        self.assertEqual(result['code'], 'response_incomplete')
+
+    def test_json_fallback_and_http_error_are_not_stream_success(self):
+        self.assertEqual(self.run_probe()['code'], 'unexpected_response')
+        self.status = 500
+        self.assertEqual(self.run_probe()['code'], 'http_error')
+
+    def test_bytewise_utf8_multiline_and_redaction(self):
+        wire = frame('你好 fake-api-key', 'stop') + b'data: [DONE]\n\n'
+        class Response:
+            def __init__(self):
+                self.parts = iter(bytes([b]) for b in wire)
+            def read1(self, _size):
+                return next(self.parts, b'')
+        result = {'ok': False, 'content_length': len(wire), 'transfer_encoding': ''}
+        probe.read_sse(Response(), result, ['fake-api-key'], time.perf_counter(), 2)
+        self.assertTrue(result['ok'])
+        self.assertNotIn('fake-api-key', result['assistant_text'])
+
+    def test_cli_and_timeout_cleanup(self):
+        import contextlib
+        import io
+        with patch.object(probe.sys, 'argv', ['test_deepseek_proxy_stream.py']), patch.object(probe, 'probe', return_value={'ok': True}), contextlib.redirect_stdout(io.StringIO()):
+            self.assertEqual(probe.main(), 0)
+        with patch.object(probe, 'HTTPConnection') as connection:
+            connection.return_value.getresponse.side_effect = TimeoutError
+            self.assertEqual(self.run_probe()['code'], 'timeout')
+            connection.return_value.close.assert_called_once()

+ 180 - 0
scripts/tests/test_deepseek_transport.py

@@ -0,0 +1,180 @@
+"""Production clients against a synthetic gateway; no real settings or network."""
+import contextlib
+import io
+import json
+import os
+from pathlib import Path
+import tempfile
+import threading
+import unittest
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from unittest.mock import patch
+
+from scripts import deploy_check
+from src.step1_knowledge_prepare.summarize import Config, chat_endpoint, load_config, transport, RequestFailure
+from src.step3_question_answer.streaming import stream_transport
+
+
+class EndpointTests(unittest.TestCase):
+    def test_base_and_complete_urls_preserve_query(self):
+        for base, expected in (
+            ('https://example.invalid', 'https://example.invalid/chat/completions'),
+            ('https://example.invalid/v1/', 'https://example.invalid/v1/chat/completions'),
+            ('https://example.invalid/chat/completions', 'https://example.invalid/chat/completions'),
+            ('http://example.invalid:123/proxy/', 'http://example.invalid:123/proxy/chat/completions'),
+            ('http://example.invalid/proxy/chat/completions/', 'http://example.invalid/proxy/chat/completions'),
+        ):
+            query = '?servertype=deepseek&proxyToken=fake%2B%26%2f&x=1&x=2'
+            with self.subTest(base=base):
+                self.assertEqual(chat_endpoint(base + query), expected + query)
+
+    def test_invalid_urls_have_safe_errors(self):
+        for url in ('file:///secret', 'http://user:secret@example.invalid',
+                    'http://example.invalid:bad', 'http://example.invalid/#secret',
+                    'http://example.invalid/\nsecret', 'http://', 'http://example.invalid\\secret'):
+            with self.subTest(url=url), self.assertRaisesRegex(ValueError, '^invalid_deepseek_base_url$'):
+                chat_endpoint(url)
+
+
+class GatewayTests(unittest.TestCase):
+    def setUp(self):
+        self.calls = []
+        self.mode = 'normal'
+        self.request_headers = []
+        self.first_received = threading.Event()
+        test = self
+
+        class Handler(BaseHTTPRequestHandler):
+            protocol_version = 'HTTP/1.1'
+            def log_message(self, *_):
+                pass
+
+            def do_POST(self):
+                payload = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
+                test.calls.append((self.path, self.headers.get('Authorization'), payload))
+                test.request_headers.append(dict(self.headers))
+                if test.mode == 'redirect':
+                    self.send_response(302)
+                    self.send_header('Location', '/other')
+                    self.send_header('Content-Length', '0')
+                    self.end_headers()
+                    return
+                self.send_response(200)
+                if test.mode == 'truncated':
+                    self.send_header('Transfer-Encoding', 'chunked')
+                    self.end_headers()
+                    self.wfile.write(b'broken\r\n')
+                    self.close_connection = True
+                    return
+                if payload.get('stream'):
+                    self.send_header('Content-Type', 'text/event-stream')
+                    self.send_header('Transfer-Encoding', 'chunked')
+                    self.end_headers()
+                    def write_chunk(data):
+                        self.wfile.write(f'{len(data):x}\r\n'.encode() + data + b'\r\n')
+                        self.wfile.flush()
+                    if test.mode == 'incremental':
+                        write_chunk(b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n')
+                        if not test.first_received.wait(2):
+                            self.close_connection = True
+                            return
+                    frame = {'choices': [{'delta': {'content': 'OK'}, 'finish_reason': 'stop'}]}
+                    write_chunk(('data: ' + json.dumps(frame) + '\n\ndata: [DONE]\n\n').encode())
+                    if test.mode == 'done_truncated':
+                        self.close_connection = True
+                    else:
+                        self.wfile.write(b'0\r\n\r\n')
+                else:
+                    body = b'{"choices":[{"message":{"content":"OK"}}]}'
+                    self.send_header('Content-Length', str(len(body)))
+                    self.end_headers()
+                    self.wfile.write(body)
+
+        self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+        self.thread.start()
+        self.temp = tempfile.TemporaryDirectory()
+        self.root = Path(self.temp.name)
+        self.url = (f'http://127.0.0.1:{self.server.server_port}/proxy/chat/completions'
+                    '?servertype=deepseek&proxyToken=fake%2B%26value')
+        (self.root / '.env').write_text('DEEPSEEK_BASE_URL=' + self.url +
+            '\nDEEPSEEK_API_KEY=fake-key\nDEEPSEEK_MODEL=fake-model\n', encoding='utf-8')
+        self.env = patch.dict(os.environ, {'HTTP_PROXY': 'http://127.0.0.1:1',
+            'http_proxy': 'http://127.0.0.1:1', 'NO_PROXY': '', 'no_proxy': ''}, clear=True)
+        self.env.start()
+
+    def tearDown(self):
+        self.env.stop()
+        self.server.shutdown()
+        self.server.server_close()
+        self.thread.join()
+        self.temp.cleanup()
+
+    def test_production_json_stream_and_deployment_check(self):
+        config = load_config(self.root / '.env')
+        payload = {'model': config.model, 'messages': [{'role': 'user', 'content': 'synthetic'}]}
+        self.assertEqual(transport(payload, config)['choices'][0]['message']['content'], 'OK')
+        parts = []
+        stream_transport(payload, config, parts.append)
+        self.assertEqual(parts, ['OK'])
+        output = io.StringIO()
+        with contextlib.redirect_stdout(output):
+            self.assertEqual(deploy_check.run(self.root, action='check-deepseek'), 0)
+        self.assertTrue(json.loads(output.getvalue())['checks']['deepseek']['ok'])
+        self.assertEqual(len(self.calls), 3)
+        for headers in self.request_headers:
+            self.assertEqual(headers['Connection'], 'keep-alive')
+            self.assertEqual(headers['Accept'], '*/*')
+        for path, auth, body in self.calls:
+            self.assertEqual(path, '/proxy/chat/completions?servertype=deepseek&proxyToken=fake%2B%26value')
+            self.assertEqual(auth, 'Bearer fake-key')
+            self.assertEqual(body['model'], 'fake-model')
+            self.assertTrue(body['messages'])
+        self.assertNotIn('fake-key', repr(config))
+        self.assertNotIn('proxyToken', output.getvalue())
+
+    def test_truncated_response_and_redirect_report_safe_codes(self):
+        for mode, code in (('truncated', 'response_incomplete'), ('redirect', 'http_302')):
+            self.mode = mode
+            output = io.StringIO()
+            with contextlib.redirect_stdout(output):
+                self.assertEqual(deploy_check.run(self.root, action='check-deepseek'), 1)
+            self.assertEqual(json.loads(output.getvalue())['checks']['deepseek']['code'], code)
+            stream_code = code
+            with self.assertRaisesRegex(RequestFailure, '^' + stream_code + '$'):
+                stream_transport({}, load_config(self.root / '.env'), lambda _: None)
+        self.assertEqual(len(self.calls), 4)
+
+    def test_first_delta_is_delivered_before_server_finishes(self):
+        self.mode = 'incremental'
+        parts = []
+        def consume(text):
+            parts.append(text)
+            self.first_received.set()
+        response = stream_transport({}, load_config(self.root / '.env'), consume)
+        self.assertEqual(parts, ['first', 'OK'])
+        self.assertEqual(response['choices'][0]['message']['content'], 'firstOK')
+
+    def test_done_does_not_mask_broken_chunked_end(self):
+        self.mode = 'done_truncated'
+        with self.assertRaisesRegex(RequestFailure, '^response_incomplete$'):
+            stream_transport({}, load_config(self.root / '.env'), lambda _: None)
+
+    def test_gateway_connections_closed_on_timeout_and_callback_failure(self):
+        from src.step1_knowledge_prepare import summarize
+        config = load_config(self.root / '.env')
+        with patch.object(summarize, 'HTTPConnection') as factory:
+            factory.return_value.getresponse.side_effect = TimeoutError('secret-url')
+            with self.assertRaisesRegex(RequestFailure, '^request_timeout$'):
+                transport({}, config)
+            factory.return_value.close.assert_called_once()
+        with patch.object(summarize, 'HTTPConnection') as factory:
+            response = factory.return_value.getresponse.return_value
+            response.__enter__.return_value = response
+            response.status = 200
+            response.read1.return_value = b'data: {"choices":[{"delta":{"content":"x"}}]}\n\n'
+            def cancel(_):
+                raise RequestFailure('stream_cancelled', False)
+            with self.assertRaisesRegex(RequestFailure, '^stream_cancelled$'):
+                stream_transport({}, config, cancel)
+            factory.return_value.close.assert_called_once()

+ 147 - 0
scripts/tests/test_deploy_signals.py

@@ -0,0 +1,147 @@
+"""Synthetic process identities and signals; never signal a real process."""
+from contextlib import nullcontext
+import errno
+from pathlib import Path
+import signal
+from tempfile import TemporaryDirectory
+from types import SimpleNamespace
+import unittest
+from unittest.mock import Mock, patch
+
+from scripts import deploy
+
+
+class SignalCompatibilityTests(unittest.TestCase):
+    def setUp(self):
+        temporary = TemporaryDirectory()
+        self.addCleanup(temporary.cleanup)
+        self.root = Path(temporary.name)
+        self.deployment = deploy.Deployment(self.root)
+        self.identity = {'pid': 4321, 'start': '100', 'boot': 'synthetic-boot'}
+        self.deployment.identity = Mock(return_value=self.identity)
+
+    def test_missing_both_or_one_function_uses_checked_pid(self):
+        for available in ('neither', 'open_only', 'send_only'):
+            with self.subTest(available=available):
+                opened, sent, killed = Mock(), Mock(), Mock()
+                fake_os = SimpleNamespace(kill=killed)
+                fake_signal = SimpleNamespace()
+                if available == 'open_only':
+                    fake_os.pidfd_open = opened
+                if available == 'send_only':
+                    fake_signal.pidfd_send_signal = sent
+                self.deployment.identity.reset_mock()
+                with patch.object(deploy, 'os', fake_os), patch.object(deploy, 'signal', fake_signal):
+                    self.deployment.send(self.identity, 15)
+                killed.assert_called_once_with(4321, 15)
+                self.assertEqual(self.deployment.identity.call_count, 2)
+                opened.assert_not_called()
+                sent.assert_not_called()
+
+    def test_identity_change_exit_or_other_checkout_never_signals_in_fallback(self):
+        for replacement in (None, {**self.identity, 'start': '101'}, {**self.identity, 'boot': 'new-boot'}):
+            for stage in ('initial', 'final'):
+                with self.subTest(replacement=replacement, stage=stage):
+                    self.deployment.identity = Mock(side_effect=[replacement] if stage == 'initial'
+                                                    else [self.identity, replacement])
+                    kill = Mock()
+                    with patch.object(deploy, 'os', SimpleNamespace(kill=kill)), \
+                            patch.object(deploy, 'signal', SimpleNamespace()):
+                        self.deployment.send(self.identity, 15)
+                    kill.assert_not_called()
+
+    def test_pidfd_remains_preferred_and_fd_is_closed(self):
+        with patch.object(deploy.os, 'pidfd_open', return_value=88, create=True) as opened, \
+                patch.object(deploy.signal, 'pidfd_send_signal', create=True) as send, \
+                patch.object(deploy.os, 'close') as close, patch.object(deploy.os, 'kill') as kill:
+            self.deployment.send(self.identity, signal.SIGTERM)
+        opened.assert_called_once_with(4321)
+        send.assert_called_once_with(88, signal.SIGTERM)
+        close.assert_called_once_with(88)
+        kill.assert_not_called()
+
+    def test_kernel_unsupported_open_or_send_falls_back_and_closes_fd(self):
+        for stage in ('open', 'send'):
+            for code in (errno.ENOSYS, errno.ENOTSUP):
+                with self.subTest(stage=stage, code=code):
+                    with patch.object(deploy.os, 'pidfd_open', return_value=88, create=True) as opened, \
+                            patch.object(deploy.signal, 'pidfd_send_signal', create=True) as send, \
+                            patch.object(deploy.os, 'close') as close, patch.object(deploy.os, 'kill') as kill:
+                        (opened if stage == 'open' else send).side_effect = OSError(code, 'unsupported')
+                        self.deployment.send(self.identity, 15)
+                    kill.assert_called_once_with(4321, 15)
+                    if stage == 'send':
+                        close.assert_called_once_with(88)
+                    else:
+                        close.assert_not_called()
+
+    def test_permission_and_other_errors_do_not_fall_back(self):
+        for stage in ('open', 'send'):
+            for code in (errno.EPERM, errno.EBADF, errno.EINVAL):
+                with self.subTest(stage=stage, code=code):
+                    with patch.object(deploy.os, 'pidfd_open', return_value=88, create=True) as opened, \
+                            patch.object(deploy.signal, 'pidfd_send_signal', create=True) as send, \
+                            patch.object(deploy.os, 'close') as close, patch.object(deploy.os, 'kill') as kill:
+                        (opened if stage == 'open' else send).side_effect = OSError(code, 'synthetic')
+                        with self.assertRaises(OSError):
+                            self.deployment.send(self.identity, 15)
+                    kill.assert_not_called()
+                    self.assertEqual(close.call_count, int(stage == 'send'))
+
+    def test_disappearing_process_during_signalling_is_already_stopped(self):
+        for stage in ('open', 'send', 'kill'):
+            with self.subTest(stage=stage):
+                with patch.object(deploy.os, 'pidfd_open', return_value=88, create=True) as opened, \
+                        patch.object(deploy.signal, 'pidfd_send_signal', create=True) as send, \
+                        patch.object(deploy.os, 'close') as close, patch.object(deploy.os, 'kill') as kill:
+                    if stage == 'kill':
+                        opened.side_effect = OSError(errno.ENOSYS, 'unsupported')
+                        kill.side_effect = ProcessLookupError()
+                    else:
+                        (opened if stage == 'open' else send).side_effect = ProcessLookupError()
+                    self.deployment.send(self.identity, 15)
+                self.assertEqual(kill.call_count, int(stage == 'kill'))
+                self.assertEqual(close.call_count, int(stage == 'send'))
+
+    def test_fallback_permission_failure_is_not_reported_as_success(self):
+        kill = Mock(side_effect=PermissionError())
+        with patch.object(deploy, 'os', SimpleNamespace(kill=kill)), \
+                patch.object(deploy, 'signal', SimpleNamespace()):
+            with self.assertRaises(PermissionError):
+                self.deployment.send(self.identity, 15)
+
+    def test_nonpositive_or_special_pids_are_rejected(self):
+        for pid in (-1, 0, 1, '4321', True):
+            with self.subTest(pid=pid), patch.object(deploy.os, 'kill') as kill:
+                with self.assertRaisesRegex(RuntimeError, 'invalid_api_process_identity'):
+                    self.deployment.send({**self.identity, 'pid': pid}, 15)
+                kill.assert_not_called()
+
+    def test_restart_without_pidfd_stops_old_process_before_starting(self):
+        (self.root / '.env').write_text('# synthetic only\n')
+        running = [self.identity]
+        self.deployment.identity = Mock(side_effect=lambda pid: running[0] if running else None)
+        self.deployment.processes = Mock(side_effect=lambda: list(running))
+        self.deployment.systemd_managed = Mock(return_value=False)
+        self.deployment.lock = Mock(return_value=nullcontext())
+        self.deployment.settings = Mock()
+        self.deployment.sync = Mock()
+        self.deployment.activate_runtime = Mock()
+        self.deployment.pidfile.write_text('synthetic old identity')
+
+        def killed(pid, sig):
+            self.assertEqual((pid, sig), (4321, signal.SIGTERM))
+            running.clear()
+
+        def started():
+            self.assertFalse(running)
+            self.assertFalse(self.deployment.pidfile.exists())
+
+        self.deployment.start = Mock(side_effect=started)
+        with patch.object(deploy.os, 'pidfd_open', None, create=True), \
+                patch.object(deploy.signal, 'pidfd_send_signal', None, create=True), \
+                patch.object(deploy.signal, 'SIGKILL', 9, create=True), \
+                patch.object(deploy.os, 'kill', side_effect=killed) as kill:
+            self.assertEqual(self.deployment.execute('restart'), 0)
+        kill.assert_called_once_with(4321, signal.SIGTERM)
+        self.deployment.start.assert_called_once()

+ 191 - 0
scripts/tests/test_dms_connection.py

@@ -0,0 +1,191 @@
+import contextlib
+import io
+import json
+import os
+from pathlib import Path
+import socket
+import tempfile
+import unittest
+from unittest.mock import patch
+from urllib.error import URLError, HTTPError
+
+from scripts import test_dms_connection as probe
+from src.dms_schema import DEFAULT_SCHEMA
+
+
+class DiagnosticTests(unittest.TestCase):
+    def test_compact_output_keeps_failures_and_hides_matched_fields(self):
+        self.mismatch = True
+        with patch('urllib.request.OpenerDirector.open', side_effect=self.fake), contextlib.redirect_stderr(io.StringIO()):
+            report, _ = probe.run(self.root, ('records', 'honor'), env_file_only=True)
+        short = probe.compact_report(report, 2)
+        self.assertEqual(short['passed_columns'], 1)
+        self.assertEqual(short['failed_columns'], 1)
+        self.assertNotIn('honor', short['failed_checks'])
+        self.assertEqual({f['status'] for f in short['failed_checks']['records']['fields']}, {'missing', 'type_mismatch'})
+        self.assertEqual(len(short['related_requests']), 1)
+        self.assertIn('getModelById', short['related_requests'][0]['endpoint'])
+        early = probe.compact_report({'ok': False, 'stage': 'authentication', 'code': 'dms_login_failed',
+            'checks': {}, 'requests': [{'http_status': 404, 'error': 'dms_http_error'}]}, 5)
+        self.assertEqual(early['not_checked_columns'], 5)
+        self.assertEqual(early['related_requests'][0]['http_status'], 404)
+
+    def test_cli_default_quiet_and_verbose_opt_in(self):
+        def fake_run(*args, **kwargs):
+            probe.emit('request_start', {'endpoint': 'synthetic'})
+            return {'ok': True, 'checks': {'records': {'ok': True, 'fields': [{'status': 'matched'}]}}, 'requests': []}, set()
+        for verbose in (False, True):
+            out, err = io.StringIO(), io.StringIO()
+            argv = ['test_dms_connection.py', '--column', 'records'] + (['--verbose'] if verbose else [])
+            with patch.object(probe, 'run', side_effect=fake_run), patch('sys.argv', argv), \
+                    contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+                self.assertEqual(probe.main(), 0)
+            result = json.loads(out.getvalue())
+            if verbose:
+                self.assertIn('request_start', err.getvalue())
+                self.assertIn('checks', result)
+            else:
+                self.assertEqual(err.getvalue(), '')
+                self.assertEqual(result, {'ok': True, 'checked_columns': 1, 'passed_columns': 1,
+                                          'failed_columns': 0, 'not_checked_columns': 0})
+
+    def test_command_line_overrides_with_old_config_without_login_url(self):
+        from types import SimpleNamespace
+        original_loader = probe.load_dms_config
+        def legacy_loader(*args, **kwargs):
+            config = original_loader(*args, **kwargs)
+            values = {k: v for k, v in vars(config).items() if k != 'login_url'}
+            return SimpleNamespace(**values, check_scope=config.check_scope, alias_for=config.alias_for)
+        def fake(request, timeout):
+            if request.full_url == 'http://auth.invalid:8888/oauth/api/user/login':
+                return Response({'code': 200, 'message': 'new-private-token'})
+            self.assertTrue(request.full_url.startswith('http://data.invalid:10081/dms/'))
+            return self.fake(request, timeout)
+        (self.root / '.env').write_text('DMS_BASE_URL=http://localhost\nAUTH_PORT=9999\nDMS_PORT=9998\n'
+                                      'DMS_USERNAME=private-user\nDMS_PASSWORD=private-password\n')
+        original = (self.root / '.env').read_bytes()
+        with patch.object(probe, 'load_dms_config', side_effect=legacy_loader), \
+                patch('src.step5_session_analysis.dms.connection_urls',
+                      return_value=('http://localhost:9998/proxy_dms', 'http://localhost:9999/proxy_oauth/user/login')), \
+                patch('urllib.request.OpenerDirector.open', side_effect=fake), contextlib.redirect_stderr(io.StringIO()):
+            report, _ = probe.run(self.root, ('records',), env_file_only=True,
+                api_url='http://data.invalid:10081/dms', login_url='http://auth.invalid:8888/oauth/api/user/login')
+        self.assertTrue(report['ok'], report)
+        self.assertEqual(report['login_url_source'], 'command_line')
+        self.assertEqual((self.root / '.env').read_bytes(), original)
+
+    def test_invalid_override_never_sends_request(self):
+        for url in ('http://user:secret@invalid/login', 'http://invalid/login?token=secret',
+                    'http://invalid:99999/login', 'http://invalid/a b', 'relative', ''):
+            with patch('urllib.request.OpenerDirector.open') as send, contextlib.redirect_stderr(io.StringIO()):
+                report, _ = probe.run(self.root, ('records',), login_url=url)
+            self.assertEqual(report['code'], 'invalid_endpoint_override')
+            send.assert_not_called()
+
+    def setUp(self):
+        self.temp = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temp.cleanup)
+        self.root = Path(self.temp.name)
+        (self.root / 'config').mkdir()
+        self.schema = json.loads(json.dumps(DEFAULT_SCHEMA))
+        (self.root / 'config/dms_schema.json').write_text(json.dumps(self.schema), encoding='utf-8')
+        (self.root / '.env').write_text('DMS_BASE_URL=http://localhost:1234\nDMS_TOKEN=synthetic-private-token\n', encoding='utf-8')
+        self.forms = []
+        self.mismatch = False
+
+    def fake(self, request, timeout):
+        from urllib.parse import parse_qs
+        form = parse_qs(request.data.decode())
+        self.forms.append((request.full_url, form))
+        if request.full_url.endswith('getColumnById'):
+            identity = int(form['id'][0])
+            spec = next((v for v in self.schema['columns'].values() if v['id'] == identity), None)
+            content = {'title': spec['name'], 'type': 1, 'parentId': 999, 'modelId': identity} if spec else {'title': self.schema['parent']}
+        else:
+            spec = next(v for v in self.schema['columns'].values() if v['id'] == int(form['modelId'][0]))
+            raw = {actual: {'type': spec.get('types', {}).get(logical, 'text')} for logical, actual in spec['fields'].items()}
+            if self.mismatch:
+                raw.pop('c_question', None)
+                if 'c_created_at' in raw:
+                    raw['c_created_at']['type'] = 'text'
+            content = {'fieldList': json.dumps(raw)}
+        return Response({'code': 200, 'content': content})
+
+    def test_all_columns_requests_and_preflight_logs(self):
+        logs = io.StringIO()
+        with patch('urllib.request.OpenerDirector.open', side_effect=self.fake), contextlib.redirect_stderr(logs):
+            report, secrets = probe.run(self.root, probe.ALIASES, env_file_only=True)
+        self.assertTrue(report['ok'], report)
+        self.assertEqual(len(self.forms), 15)
+        self.assertTrue(all('/content/' not in url for url, _ in self.forms))
+        self.assertIn('connection_parameters', logs.getvalue())
+        self.assertIn('column_start', logs.getvalue())
+        self.assertIn('"id":', logs.getvalue())
+        self.assertNotIn('synthetic-private-token', logs.getvalue() + probe.redact_report(report, secrets))
+
+    def test_missing_and_wrong_field_types_are_specific(self):
+        self.mismatch = True
+        with patch('urllib.request.OpenerDirector.open', side_effect=self.fake), contextlib.redirect_stderr(io.StringIO()):
+            report, _ = probe.run(self.root, ('records',), env_file_only=True)
+        check = report['checks']['records']
+        self.assertFalse(check['ok'])
+        fields = {v['logical_field']: v for v in check['fields']}
+        self.assertEqual(fields['c_question']['status'], 'missing')
+        self.assertEqual(fields['c_created_at']['status'], 'type_mismatch')
+        self.assertEqual(fields['c_created_at']['expected_type'], 'timestamp')
+
+    def test_scope_rejection_makes_no_requests(self):
+        with (self.root / '.env').open('a') as f:
+            f.write('DMS_STRICT_SCOPE=true\nDMS_ALLOWED_SCOPES_JSON={}\n')
+        with patch('urllib.request.OpenerDirector.open') as send, contextlib.redirect_stderr(io.StringIO()):
+            report, _ = probe.run(self.root, ('records',), env_file_only=True)
+        self.assertEqual(report['code'], 'dms_scope_denied')
+        send.assert_not_called()
+
+    def test_network_http_and_auth_failures_keep_diagnostics(self):
+        for error, expected in [(URLError(socket.gaierror(-2, 'secret-error')), 'dns_failed'),
+                                (URLError(ConnectionRefusedError(111, 'secret-error')), 'connection_refused'),
+                                (URLError(TimeoutError('secret-error')), 'timeout')]:
+            with patch('urllib.request.OpenerDirector.open', side_effect=error), contextlib.redirect_stderr(io.StringIO()):
+                report, _ = probe.run(self.root, ('records',), env_file_only=True)
+            self.assertEqual(report['requests'][0]['error'], expected)
+            self.assertNotIn('secret-error', json.dumps(report))
+        error = HTTPError('http://localhost', 404, 'secret-error', {}, io.BytesIO(b'secret-body'))
+        with patch('urllib.request.OpenerDirector.open', side_effect=error), contextlib.redirect_stderr(io.StringIO()):
+            report, _ = probe.run(self.root, ('records',), env_file_only=True)
+        self.assertEqual(report['requests'][0]['http_status'], 404)
+        self.assertNotIn('secret-body', json.dumps(report))
+        with patch('urllib.request.OpenerDirector.open', return_value=Response({'code': 212})), contextlib.redirect_stderr(io.StringIO()):
+            report, _ = probe.run(self.root, ('records',), env_file_only=True)
+        self.assertEqual(report['checks']['records']['code'], 'dms_token_invalid')
+
+    def test_login_values_and_returned_token_never_printed(self):
+        (self.root / '.env').write_text('DMS_BASE_URL=http://localhost:1234/dms\nDMS_LOGIN_URL=http://localhost:8888/oauth/api/user/login\nDMS_USERNAME=private-user\nDMS_PASSWORD=private-password\n')
+        def fake(request, timeout):
+            if request.full_url.endswith('/user/login'):
+                self.assertEqual(request.full_url, 'http://localhost:8888/oauth/api/user/login')
+                return Response({'code': 200, 'message': 'fresh-private-token'})
+            return self.fake(request, timeout)
+        logs = io.StringIO()
+        with patch('urllib.request.OpenerDirector.open', side_effect=fake), contextlib.redirect_stderr(logs):
+            report, secrets = probe.run(self.root, ('records',), env_file_only=True)
+        self.assertTrue(report['ok'])
+        self.assertEqual(report['api_endpoint'], 'http://localhost:1234/dms')
+        text = logs.getvalue() + probe.redact_report(report, secrets)
+        for secret in ('private-user', 'private-password', 'fresh-private-token'):
+            self.assertNotIn(secret, text)
+        self.assertIn('"serviceId": "0"', text)
+
+
+class Response:
+    status = 200
+    length = None
+    headers = {'Content-Type': 'application/json'}
+    def __init__(self, payload):
+        self.payload = payload
+    def __enter__(self):
+        return self
+    def __exit__(self, *_):
+        pass
+    def read(self, limit):
+        return json.dumps(self.payload).encode()[:limit]

+ 33 - 0
scripts/tests/test_dms_deploy_routes.py

@@ -0,0 +1,33 @@
+"""Deployment connection isolation and direct split-port routes."""
+import os
+import unittest
+from unittest.mock import patch
+
+from scripts.deploy import apply_api_env_policy
+from src.step5_session_analysis.dms import DmsConfig, connection_urls
+
+
+class DmsDeployRoutesTests(unittest.TestCase):
+    def test_routes_do_not_depend_on_host_or_specific_ports(self):
+        for host in ('http://example.invalid', 'https://[::1]'):
+            for suffix in ('', '/'):
+                config = DmsConfig(host + suffix, auth_port=8888, dms_port=10081)
+                self.assertEqual(connection_urls(config),
+                                 (host + ':10081/dms', host + ':8888/oauth/api/user/login'))
+            config = DmsConfig(host + '/proxy_dms', auth_port=8888, dms_port=10081)
+            self.assertEqual(connection_urls(config),
+                             (host + ':10081/proxy_dms', host + ':8888/proxy_oauth/user/login'))
+
+    def test_env_policy_clears_connections_but_preserves_scope_and_mapping(self):
+        keys = ('DMS_BASE_URL', 'DMS_LOGIN_URL', 'AUTH_PORT', 'DMS_PORT',
+                'DMS_USERNAME', 'DMS_PASSWORD', 'DMS_TOKEN')
+        env = dict.fromkeys(keys, 'synthetic')
+        env.update(DMS_STRICT_SCOPE='true', DMS_RECORDS_COLUMN_ID='9999')
+        with patch.dict(os.environ, env, clear=True):
+            apply_api_env_policy()
+            self.assertTrue(all(key in os.environ for key in keys))
+            os.environ['DEPLOY_API_ENV_FILE_ONLY'] = '1'
+            apply_api_env_policy()
+            self.assertTrue(all(key not in os.environ for key in keys))
+            self.assertEqual(os.environ['DMS_STRICT_SCOPE'], 'true')
+            self.assertEqual(os.environ['DMS_RECORDS_COLUMN_ID'], '9999')

+ 203 - 0
scripts/tests/test_qcc_proxy.py

@@ -0,0 +1,203 @@
+import contextlib
+import hashlib
+import io
+import json
+import os
+from pathlib import Path
+import tempfile
+import threading
+import unittest
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlsplit
+
+from scripts import test_qcc_proxy as probe
+
+
+class QccProbeTests(unittest.TestCase):
+    def test_signature_refreshes_for_each_request(self):
+        for timestamp in (1700000000, 1700000861):
+            with patch.object(probe.time, 'time', return_value=timestamp):
+                result = probe.probe('search', self.values, 'synthetic', 2)
+            self.assertTrue(result['ok'])
+            headers = self.calls[-1][2]
+            self.assertEqual(headers['Timespan'], str(timestamp))
+            self.assertEqual(headers['Token'], hashlib.md5(
+                ('fake-key' + str(timestamp) + 'fake-secret').encode()).hexdigest().upper())
+        self.assertNotEqual(self.calls[0][2]['Token'], self.calls[1][2]['Token'])
+
+    def test_clock_diagnostics_and_invalid_dates(self):
+        date = 'Tue, 14 Nov 2023 22:27:41 GMT'
+        result = probe.clock_diagnostics('1700000000', [date], .35)
+        self.assertEqual(result['response_date_minus_request_seconds'], 861)
+        self.assertTrue(result['clock_skew_suspected'])
+        self.assertFalse(probe.clock_diagnostics('1700000861', [date], .35)['clock_skew_suspected'])
+        self.assertTrue(probe.clock_diagnostics('1700001722', [date], .35)['clock_skew_suspected'])
+        for dates in ([], ['bad'], [date, date]):
+            self.assertNotIn('clock_skew_suspected', probe.clock_diagnostics('1700000000', dates, .35))
+
+    def test_env_file_only_ignores_stale_credentials(self):
+        with tempfile.TemporaryDirectory() as folder:
+            path = Path(folder) / '.env'
+            path.write_text('QCC_KEY=file-key\nQCC_SECRET_KEY=file-secret\n', encoding='utf-8')
+            with patch.dict(os.environ, {'QCC_KEY': 'old-key', 'QCC_SECRET_KEY': 'old-secret'}):
+                self.assertEqual(probe.load_settings(path)['QCC_KEY'], 'old-key')
+                values = probe.load_settings(path, env_file_only=True)
+                self.assertEqual(values['QCC_KEY'], 'file-key')
+                self.assertEqual(values['QCC_SECRET_KEY'], 'file-secret')
+
+    def setUp(self):
+        self.calls = []
+        self.status = 200
+        self.broken = False
+        self.raw_body = None
+        self.payloads = {
+            'fuzzySearch': {'Status': '200', 'Result': [{'Name': '合成企业', 'KeyNo': 'synthetic'}]},
+            'basicDetails': {'Status': '200', 'Result': {'Name': '合成企业', 'KeyNo': 'synthetic'}},
+            'honor': {'Status': '200', 'Result': {'VerifyResult': 0, 'Data': None}},
+        }
+        test = self
+        class Handler(BaseHTTPRequestHandler):
+            protocol_version = 'HTTP/1.1'
+            def log_message(self, *_):
+                pass
+            def do_GET(self):
+                query = parse_qs(urlsplit(self.path).query)
+                test.calls.append((self.path, query, dict(self.headers)))
+                self.send_response(test.status)
+                if test.status == 302:
+                    self.send_header('Location', '/other')
+                route = query.get('servertype', ['fuzzySearch'])[0]
+                body = test.raw_body if test.raw_body is not None else json.dumps(test.payloads[route]).encode()
+                self.send_header('Content-Type', 'text/html' if test.raw_body else 'application/json')
+                self.send_header('X-Request-ID', 'synthetic-trace')
+                if test.broken:
+                    self.send_header('Transfer-Encoding', 'chunked')
+                    self.end_headers()
+                    self.wfile.write(b'broken\r\n')
+                    self.close_connection = True
+                else:
+                    self.send_header('Content-Length', str(len(body)))
+                    self.end_headers()
+                    self.wfile.write(body)
+        self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+        self.thread.start()
+        self.values = {'QCC_KEY': 'fake-key', 'QCC_SECRET_KEY': 'fake-secret'}
+        for name, route in zip(probe.URL_KEYS.values(), self.payloads):
+            self.values[name] = f'http://127.0.0.1:{self.server.server_port}/proxy/?servertype={route}&proxyToken=fake%2B%26token'
+
+    def tearDown(self):
+        self.server.shutdown()
+        self.server.server_close()
+        self.thread.join()
+
+    def test_three_get_requests_signatures_query_and_no_sensitive_output(self):
+        with patch.dict(os.environ, {'HTTP_PROXY': 'http://invalid.example:1'}):
+            for api in probe.URL_KEYS:
+                result = probe.probe(api, self.values, '合成企业', 2)
+                self.assertTrue(result['ok'], result)
+                self.assertNotIn('fake', json.dumps(result))
+                self.assertNotIn('合成企业', json.dumps(result, ensure_ascii=False))
+        self.assertEqual(len(self.calls), 3)
+        for path, query, headers in self.calls:
+            self.assertIn('proxyToken=fake%2B%26token&key=', path)
+            self.assertEqual(headers['Connection'], 'keep-alive')
+            self.assertEqual(headers['Token'], hashlib.md5(('fake-key' + headers['Timespan'] + 'fake-secret').encode()).hexdigest().upper())
+            self.assertEqual(query['key'], ['fake-key'])
+        self.assertEqual(self.calls[0][1]['searchKey'], ['合成企业'])
+        self.assertEqual(self.calls[1][1]['keyword'], ['合成企业'])
+        self.assertEqual(self.calls[2][1]['pageSize'], ['20'])
+
+    def test_business_failure_and_empty_distinction(self):
+        self.payloads['fuzzySearch'] = {'Status': '201'}
+        self.assertEqual(probe.probe('search', self.values, 'synthetic', 2)['code'], 'no_matches')
+        self.payloads['basicDetails'] = {'Status': '201'}
+        result = probe.probe('basic', self.values, 'synthetic', 2)
+        self.assertFalse(result['ok'])
+        self.assertEqual(result['code'], 'provider_failure')
+        self.assertFalse(probe.validate_payload('honor', {'Status': '200', 'Result': {'VerifyResult': 0, 'Data': [{}]}})['ok'])
+
+    def test_invalid_url_and_case_are_not_sent(self):
+        original = self.values['QCC_SEARCH_URL']
+        for value in (original.replace('servertype', 'Servertype'), original.replace('proxyToken=', 'proxyToken-'), original.replace('127.0', '127. 0')):
+            self.values['QCC_SEARCH_URL'] = value
+            self.assertFalse(probe.probe('search', self.values, 'synthetic', 2)['ok'])
+        self.assertEqual(self.calls, [])
+
+    def test_redirect_http_error_and_broken_response(self):
+        for status in (302, 500):
+            self.status = status
+            self.assertEqual(probe.probe('search', self.values, 'synthetic', 2)['code'], 'http_error')
+        self.assertEqual(len(self.calls), 2)
+        self.status, self.broken = 200, True
+        self.assertEqual(probe.probe('search', self.values, 'synthetic', 2)['code'], 'response_incomplete')
+
+    def test_env_and_cli(self):
+        with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {}, clear=True):
+            path = Path(directory) / '.env'
+            path.write_text('export QCC_KEY="fake-key" # note\nQCC_SECRET_KEY=fake-secret\n', encoding='utf-8-sig')
+            self.assertEqual(probe.load_settings(path)['QCC_KEY'], 'fake-key')
+        with patch.object(probe, 'load_settings', return_value=self.values), patch('sys.argv', ['test_qcc_proxy.py', '--company', '合成企业']), contextlib.redirect_stdout(io.StringIO()) as output:
+            self.assertEqual(probe.main(), 0)
+        self.assertEqual(set(json.loads(output.getvalue())['checks']), {'search', 'basic', 'honor'})
+
+    def test_timeout_closes_and_does_not_leak(self):
+        with patch.object(probe, 'HTTPConnection') as conn:
+            conn.return_value.request.side_effect = TimeoutError('fake-secret')
+            result = probe.probe('search', self.values, 'synthetic', 2)
+            self.assertEqual(result['code'], 'timeout')
+            self.assertNotIn('fake', json.dumps(result))
+            conn.return_value.close.assert_called_once()
+
+    def test_400_html_body_is_visible_and_sensitive_echoes_are_redacted(self):
+        self.status = 400
+        timestamp = '1700000000'
+        signature = hashlib.md5(('fake-key' + timestamp + 'fake-secret').encode()).hexdigest().upper()
+        self.raw_body = ('<html>Bad Request: invalid upstream request fake-key fake-secret '
+                         'fake+&token fake%2b%26token 合成企业 ' + signature + '</html>').encode()
+        with patch.object(probe.time, 'time', return_value=int(timestamp)):
+            result = probe.probe('search', self.values, '合成企业', 2)
+        self.assertIn('invalid upstream request', result['error_body_preview'])
+        self.assertEqual(result['response_headers']['X-Request-ID'], ['synthetic-trace'])
+        self.assertIn('elapsed_seconds', result)
+        for value in ('fake-key', 'fake-secret', 'fake+&token', 'fake%2b%26token', '合成企业', signature):
+            self.assertNotIn(value, json.dumps(result, ensure_ascii=False))
+
+    def test_provider_message_visible_result_omitted_and_preview_bounded(self):
+        self.payloads['fuzzySearch'] = {'Status': '115', 'Message': '身份验证错误 fake-key',
+                                      'Result': {'Name': 'private-company'}, 'OrderNumber': 'test-order'}
+        result = probe.probe('search', self.values, '合成企业', 2)
+        self.assertIn('身份验证错误', result['error_body_preview'])
+        self.assertNotIn('private-company', result['error_body_preview'])
+        self.assertNotIn('fake-key', result['error_body_preview'])
+        self.raw_body = b'x' * 3000
+        self.status = 400
+        result = probe.probe('search', self.values, 'synthetic', 2)
+        self.assertEqual(len(result['error_body_preview']), 2000)
+        self.assertTrue(result['error_body_truncated'])
+
+    def test_success_never_outputs_response_body(self):
+        result = probe.probe('search', self.values, '合成企业', 2)
+        self.assertTrue(result['ok'])
+        self.assertNotIn('error_body_preview', result)
+        self.assertNotIn('合成企业', json.dumps(result, ensure_ascii=False))
+
+    def test_direct_mode_matches_proxy_business_parameters_and_headers(self):
+        with patch.object(probe.time, 'time', return_value=1700000000):
+            proxy = probe.probe('search', self.values, '合成企业', 2)
+            self.values['QCC_SEARCH_URL'] = f'http://127.0.0.1:{self.server.server_port}/FuzzySearch/GetList'
+            direct = probe.probe('search', self.values, '合成企业', 2, mode='direct')
+        self.assertTrue(proxy['ok'])
+        self.assertTrue(direct['ok'])
+        self.assertTrue(self.calls[1][0].startswith('/FuzzySearch/GetList?key='))
+        for key in ('key', 'searchKey', 'pageIndex'):
+            self.assertEqual(self.calls[0][1][key], self.calls[1][1][key])
+        for key in ('Token', 'Timespan', 'Connection', 'Accept'):
+            self.assertEqual(self.calls[0][2][key], self.calls[1][2][key])
+        self.assertFalse(probe.probe('search', self.values, '合成企业', 2)['ok'])
+
+    def test_direct_mode_rejects_proxy_url_before_request(self):
+        self.assertEqual(probe.probe('search', self.values, 'synthetic', 2, mode='direct')['code'],
+                         'direct_url_must_not_have_query')
+        self.assertEqual(self.calls, [])

+ 124 - 0
scripts/tests/test_qcc_transport.py

@@ -0,0 +1,124 @@
+"""Production QCC adapter against a local synthetic gateway only."""
+import hashlib
+import json
+import os
+from pathlib import Path
+import tempfile
+import threading
+import unittest
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlsplit
+
+from src.step3_question_answer import qcc
+from scripts import deploy, deploy_check
+
+
+class QccTransportTests(unittest.TestCase):
+    def setUp(self):
+        self.calls = []
+        self.mode = 'ok'
+        test = self
+        class Handler(BaseHTTPRequestHandler):
+            protocol_version = 'HTTP/1.1'
+            def handle(self):
+                try:
+                    super().handle()
+                except (ConnectionAbortedError, ConnectionResetError):
+                    pass  # Client deliberately closes immediately on redirects/errors.
+            def log_message(self, *_):
+                pass
+            def do_GET(self):
+                params = parse_qs(urlsplit(self.path).query)
+                test.calls.append((self.path, params, dict(self.headers)))
+                route = params['servertype'][0]
+                company = {'KeyNo': 'synthetic-id', 'Name': '合成企业', 'CreditCode': 'synthetic-credit'}
+                if route == 'fuzzySearch':
+                    result = [company]
+                elif route == 'basicDetails':
+                    result = company
+                    if test.mode == 'identity':
+                        result = {**company, 'KeyNo': 'different'}
+                else:
+                    page = int(params['pageIndex'][0])
+                    result = {'VerifyResult': 1, 'Data': [{'Name': 'synthetic'}] * (20 if page == 1 else 1)}
+                payload = {'Status': '115' if test.mode == 'auth' else '200', 'Result': result}
+                if route == 'honor':
+                    payload['Paging'] = {'TotalRecords': 21}
+                body = json.dumps(payload).encode()
+                self.send_response(302 if test.mode == 'redirect' else 200)
+                if test.mode == 'redirect':
+                    self.send_header('Location', '/do-not-follow')
+                if test.mode == 'chunk':
+                    self.send_header('Transfer-Encoding', 'chunked')
+                    self.end_headers()
+                    self.wfile.write(b'bad-chunk\r\n')
+                    self.close_connection = True
+                else:
+                    self.send_header('Content-Length', str(len(body) + (10 if test.mode == 'short' else 0)))
+                    self.end_headers()
+                    self.wfile.write(body)
+                    if test.mode == 'short':
+                        self.close_connection = True
+        self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+        self.thread.start()
+        base = f'http://127.0.0.1:{self.server.server_port}/proxy/?proxyToken=fake%2B%26token&servertype='
+        self.config = qcc.Config('synthetic-key', 'synthetic-secret', timeout=2,
+            search_url=base + 'fuzzySearch', basic_url=base + 'basicDetails', honor_url=base + 'honor')
+
+    def tearDown(self):
+        self.server.shutdown()
+        self.server.server_close()
+        self.thread.join()
+
+    def test_production_three_interfaces_and_deploy_check(self):
+        client = qcc.QccClient(self.config)
+        with patch.dict(os.environ, {'HTTP_PROXY': 'http://invalid:1'}):
+            report = deploy_check.qcc(Path('.'), '合成企业', client)
+        self.assertTrue(report['ok'], report)
+        self.assertEqual(report['interfaces']['1001']['records'], 21)
+        self.assertEqual(report['interfaces']['1001']['pages'], 2)
+        self.assertEqual(len(self.calls), 4)
+        for path, params, headers in self.calls:
+            self.assertIn('proxyToken=fake%2B%26token&', path)
+            self.assertNotIn('合成企业', path)
+            self.assertEqual(headers['Connection'], 'keep-alive')
+            self.assertEqual(headers['Accept'], '*/*')
+            self.assertEqual(params['key'], ['synthetic-key'])
+            expected = hashlib.md5(('synthetic-key' + headers['Timespan'] + 'synthetic-secret').encode()).hexdigest().upper()
+            self.assertEqual(headers['Token'], expected)
+        self.assertEqual(self.calls[1][1]['keyword'], ['合成企业'])
+
+    def test_errors_and_identity_do_not_become_success(self):
+        client = qcc.QccClient(self.config)
+        for mode, code in [('auth', 'qcc_auth_failed'), ('redirect', 'http_302'),
+                           ('short', 'response_incomplete'), ('chunk', 'response_incomplete')]:
+            self.mode = mode
+            before = len(self.calls)
+            with self.assertRaisesRegex(qcc.QccError, '^' + code + '$'):
+                client.search('合成企业')
+            self.assertEqual(len(self.calls), before + 1)
+        self.mode = 'identity'
+        with self.assertRaisesRegex(qcc.QccError, 'company_identity_mismatch'):
+            client.basic({'KeyNo': 'synthetic-id', 'Name': '合成企业'})
+
+    def test_config_validation_and_environment_isolation(self):
+        for suffix in ('&key=x', '&pageIndex=2', '&proxyToken=duplicate'):
+            self.assertFalse(qcc.valid_api_url(self.config.search_url + suffix))
+        self.assertFalse(qcc.valid_api_url(self.config.search_url.replace('servertype', 'serverType')))
+        self.assertTrue(qcc.valid_api_url(qcc.DEFAULT_SEARCH_URL))
+        self.assertTrue(qcc.valid_api_url(self.config.search_url.replace('http:', 'https:')))
+        with tempfile.TemporaryDirectory() as folder:
+            path = Path(folder) / '.env'
+            path.write_text('QCC_KEY=synthetic-key\nQCC_SECRET_KEY=synthetic-secret\nQCC_SEARCH_URL=' + self.config.search_url)
+            with patch.dict(os.environ, {'DEPLOY_API_ENV_FILE_ONLY': '1', 'QCC_KEY': 'old',
+                                        'DEEPSEEK_BASE_URL': 'old', 'DMS_TOKEN': 'preserved'}):
+                deploy.apply_api_env_policy()
+                self.assertEqual(qcc.load_config(path).key, 'synthetic-key')
+                self.assertNotIn('DEEPSEEK_BASE_URL', os.environ)
+                self.assertNotIn('DMS_TOKEN', os.environ)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 56 - 7
src/step1_knowledge_prepare/summarize.py

@@ -2,6 +2,7 @@
 
 import argparse
 import hashlib
+from http.client import HTTPConnection, HTTPSConnection, HTTPException
 import json
 import os
 import re
@@ -53,7 +54,55 @@ class Config:
 
     @property
     def endpoint(self):
-        return self.base_url.rstrip("/") + "/chat/completions"
+        return chat_endpoint(self.base_url)
+
+
+def chat_endpoint(base_url):
+    """Resolve a base or complete chat URL without rewriting signed query values."""
+    try:
+        if not isinstance(base_url, str) or any(c.isspace() for c in base_url) or "\\" in base_url:
+            raise ValueError
+        parts = urllib.parse.urlsplit(base_url)
+        if (parts.scheme not in {"http", "https"} or not parts.hostname
+                or parts.username is not None or parts.password is not None or parts.fragment):
+            raise ValueError
+        _ = parts.port
+    except ValueError:
+        raise ValueError("invalid_deepseek_base_url") from None
+    path = parts.path.rstrip("/")
+    if not path.endswith("/chat/completions"):
+        path += "/chat/completions"
+    return urllib.parse.urlunsplit(parts._replace(path=path))
+
+
+@contextmanager
+def deepseek_response(payload, config, *, stream=False):
+    """One request, with a private connection per gateway call; always close it."""
+    endpoint = config.endpoint
+    parts = urllib.parse.urlsplit(endpoint)
+    query = dict(urllib.parse.parse_qsl(parts.query))
+    gateway = query.get('servertype') == 'deepseek' and bool(query.get('proxyToken'))
+    headers = {'Authorization': 'Bearer ' + config.api_key, 'Content-Type': 'application/json',
+               'Accept': '*/*' if gateway else ('text/event-stream' if stream else 'application/json')}
+    body = dump(payload).encode('utf-8')
+    if not gateway:
+        request = urllib.request.Request(endpoint, data=body, headers=headers)
+        with urllib.request.build_opener(NoRedirect()).open(request, timeout=config.timeout) as response:
+            yield response
+        return
+    headers['Connection'] = 'keep-alive'
+    connection = (HTTPSConnection if parts.scheme == 'https' else HTTPConnection)(
+        parts.hostname, parts.port, timeout=config.timeout)
+    try:
+        target = (parts.path or '/') + ('?' + parts.query if parts.query else '')
+        connection.request('POST', target, body=body, headers=headers)
+        with connection.getresponse() as response:
+            if not 200 <= response.status < 300:
+                code = response.status
+                raise RequestFailure(f'http_{code}', not stream and (code in {408, 429} or code >= 500))
+            yield response
+    finally:
+        connection.close()
 
 
 def load_config(path, environ=None):
@@ -81,9 +130,7 @@ def load_config(path, environ=None):
     values.update({key: env[key] for key in CONFIG_KEYS if key in env})
     if not all(values.get(key) for key in ("DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL", "DEEPSEEK_MODEL")):
         raise ValueError("missing_deepseek_configuration")
-    parsed = urllib.parse.urlsplit(values["DEEPSEEK_BASE_URL"])
-    if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
-        raise ValueError("base_url_must_be_https_without_credentials_or_query")
+    chat_endpoint(values["DEEPSEEK_BASE_URL"])
     try:
         numbers = {key: int(values[key]) for key in DEFAULTS}
     except ValueError:
@@ -133,11 +180,11 @@ class RequestFailure(Exception):
 
 
 def transport(payload, config):
-    request = urllib.request.Request(config.endpoint, data=dump(payload).encode("utf-8"),
-                                     headers={"Authorization": "Bearer " + config.api_key, "Content-Type": "application/json"})
     try:
-        with urllib.request.build_opener(NoRedirect()).open(request, timeout=config.timeout) as response:
+        with deepseek_response(payload, config) as response:
             content = response.read(4_000_001)
+            if getattr(response, 'length', None) not in (None, 0) and len(content) <= 4_000_000:
+                raise RequestFailure('response_incomplete')
         if len(content) > 4_000_000:
             raise RequestFailure("response_too_large", False)
         return strict_json(content.decode("utf-8"))
@@ -151,6 +198,8 @@ def transport(payload, config):
         raise RequestFailure("request_timeout" if isinstance(exc.reason, TimeoutError) else "network_failure") from None
     except OSError:
         raise RequestFailure("network_failure") from None
+    except HTTPException:
+        raise RequestFailure("response_incomplete") from None
     except (ValueError, UnicodeError):
         raise RequestFailure("invalid_response_json") from None
 

+ 36 - 7
src/step3_question_answer/qcc.py

@@ -1,6 +1,7 @@
 """QCC GET adapter. No credentials, request URLs or response bodies in errors."""
 
 import hashlib
+from http.client import HTTPConnection, HTTPSConnection, HTTPException
 import json
 import os
 import time
@@ -17,14 +18,20 @@ DEFAULT_HONOR_URL = 'https://api.qichacha.com/HonorQualification/GetList'
 
 
 def valid_api_url(value):
-    if not isinstance(value, str) or value != value.strip() or any(ord(ch) < 33 for ch in value):
+    if not isinstance(value, str) or '\\' in value or any(ch.isspace() or ord(ch) < 33 for ch in value):
         return False
     try:
         parsed = urllib.parse.urlsplit(value)
         parsed.port  # Reject malformed ports before the URL is used.
-        return (parsed.scheme == 'https' and bool(parsed.hostname) and bool(parsed.path)
+        query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
+        names = [key for key, _ in query]
+        gateway = (names.count('servertype') == 1 and names.count('proxyToken') == 1
+                   and bool(dict(query).get('servertype')) and bool(dict(query).get('proxyToken')))
+        return (parsed.scheme in {'http', 'https'} and bool(parsed.hostname) and bool(parsed.path)
                 and parsed.username is None and parsed.password is None
-                and not parsed.query and not parsed.fragment)
+                and not parsed.fragment
+                and not set(names) & {'key', 'searchKey', 'keyword', 'pageIndex', 'pageSize'}
+                and (gateway or (parsed.scheme == 'https' and not parsed.query)))
     except ValueError:
         return False
 
@@ -80,10 +87,25 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
 
 
 def transport(url, headers, timeout):
+    connection = None
     try:
-        request = urllib.request.Request(url, headers=headers)
-        with urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) as response:
-            body = response.read(4_000_001)
+        parts = urllib.parse.urlsplit(url)
+        query = dict(urllib.parse.parse_qsl(parts.query))
+        if query.get('servertype') and query.get('proxyToken'):
+            connection = (HTTPSConnection if parts.scheme == 'https' else HTTPConnection)(
+                parts.hostname, parts.port, timeout=timeout)
+            connection.request('GET', (parts.path or '/') + '?' + parts.query,
+                               headers={**headers, 'Accept': '*/*', 'Connection': 'keep-alive'})
+            with connection.getresponse() as response:
+                if not 200 <= response.status < 300:
+                    raise QccError(f'http_{response.status}')
+                body = response.read(4_000_001)
+                if response.length not in (None, 0) and len(body) <= 4_000_000:
+                    raise QccError('response_incomplete')
+        else:
+            request = urllib.request.Request(url, headers=headers)
+            with urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) as response:
+                body = response.read(4_000_001)
         if len(body) > 4_000_000:
             raise QccError('response_too_large')
         return json.loads(body)
@@ -95,6 +117,11 @@ def transport(url, headers, timeout):
         raise QccError('network_failure') from None
     except (ValueError, UnicodeError):
         raise QccError('invalid_json') from None
+    except HTTPException:
+        raise QccError('response_incomplete') from None
+    finally:
+        if connection is not None:
+            connection.close()
 
 
 def nonempty(value):
@@ -108,9 +135,11 @@ class QccClient:
     def _get(self, api, endpoint, params):
         timestamp = str(int(self.clock()))
         token = hashlib.md5((self.config.key + timestamp + self.config.secret).encode()).hexdigest().upper()
-        url = endpoint + '?' + urllib.parse.urlencode({'key': self.config.key, **params})
+        url = endpoint + ('&' if urllib.parse.urlsplit(endpoint).query else '?') + urllib.parse.urlencode({'key': self.config.key, **params})
         payload = self.send(url, {'Token': token, 'Timespan': timestamp}, self.config.timeout)
         status = str(payload.get('Status')) if isinstance(payload, dict) else None
+        if status == '115':
+            raise QccError('qcc_auth_failed')
         # FuzzySearch uses 201 (often without Result) for no matches. Limit
         # normalization to this endpoint; other APIs have different contracts.
         no_matches = api == '886' and status == '201'

+ 41 - 10
src/step3_question_answer/streaming.py

@@ -3,12 +3,13 @@
 from contextlib import contextmanager
 from contextvars import ContextVar
 import json
+from http.client import HTTPException
 import time
 import urllib.error
 import urllib.request
 from uuid import uuid4
 
-from src.step1_knowledge_prepare.summarize import NoRedirect, RequestFailure
+from src.step1_knowledge_prepare.summarize import RequestFailure, deepseek_response
 from .timing import mark
 
 
@@ -220,19 +221,15 @@ class JsonTextField:
 
 def stream_transport(payload, config, on_content, check=lambda: None):
     """Read actual provider SSE deltas and reconstruct its normal response shape."""
-    request = urllib.request.Request(config.endpoint,
-        data=json.dumps({**payload, 'stream': True}, ensure_ascii=False).encode('utf-8'),
-        headers={'Authorization': 'Bearer ' + config.api_key, 'Content-Type': 'application/json',
-                 'Accept': 'text/event-stream'})
     total, event, parts, finish, done = 0, [], [], None, False
     started = time.monotonic()
     try:
-        with urllib.request.build_opener(NoRedirect()).open(request, timeout=config.timeout) as response:
-            while True:
+        check()
+        with deepseek_response({**payload, 'stream': True}, config, stream=True) as response:
+            for line in response_lines(response, check, started + config.timeout):
                 check()
                 if time.monotonic() - started > config.timeout:
                     raise RequestFailure('request_timeout')
-                line = response.readline(1_000_001)
                 total += len(line)
                 if total > 4_000_000 or len(line) > 1_000_000:
                     raise RequestFailure('response_too_large', False)
@@ -244,8 +241,12 @@ def stream_transport(payload, config, on_content, check=lambda: None):
                 elif not line and event:
                     data, event = '\n'.join(event), []
                     if data == '[DONE]':
+                        if done:
+                            raise RequestFailure('invalid_response_json', False)
                         done = True
-                        break
+                        continue
+                    if done:
+                        raise RequestFailure('invalid_response_json', False)
                     value = json.loads(data)
                     if not isinstance(value, dict) or 'error' in value or not isinstance(value.get('choices'), list):
                         raise RequestFailure('invalid_response_json', False)
@@ -272,7 +273,7 @@ def stream_transport(payload, config, on_content, check=lambda: None):
                         if not isinstance(reason, str) or finish is not None:
                             raise RequestFailure('invalid_response_json', False)
                         finish = reason
-        if not done or finish is None:
+        if not done or finish is None or event:
             raise RequestFailure('stream_incomplete', False)
         return {'choices': [{'finish_reason': finish, 'message': {'content': ''.join(parts)}}]}
     except urllib.error.HTTPError as exc:
@@ -285,10 +286,40 @@ def stream_transport(payload, config, on_content, check=lambda: None):
         raise RequestFailure('request_timeout' if isinstance(exc.reason, TimeoutError) else 'network_failure') from None
     except OSError:
         raise RequestFailure('network_failure') from None
+    except HTTPException:
+        raise RequestFailure('response_incomplete', False) from None
     except (ValueError, UnicodeError):
         raise RequestFailure('invalid_response_json', False) from None
 
 
+def response_lines(response, check=lambda: None, deadline=None):
+    """Incremental UTF-8-safe line framing; read1 also verifies HTTP chunks."""
+    pending = b''
+    total = 0
+    while True:
+        check()
+        if deadline is not None and time.monotonic() > deadline:
+            raise RequestFailure('request_timeout')
+        chunk = response.read1(4096)
+        if not chunk:
+            if getattr(response, 'length', None) not in (None, 0):
+                raise RequestFailure('response_incomplete', False)
+            if pending:
+                raise RequestFailure('stream_incomplete', False)
+            return
+        total += len(chunk)
+        if total > 4_000_000:
+            raise RequestFailure('response_too_large', False)
+        pending += chunk
+        while b'\n' in pending:
+            line, pending = pending.split(b'\n', 1)
+            if len(line) > 1_000_000:
+                raise RequestFailure('response_too_large', False)
+            yield line + b'\n'
+        if len(pending) > 1_000_000:
+            raise RequestFailure('response_too_large', False)
+
+
 def model_response(payload, config, fallback):
     session, spec = _session.get(), _field.get()
     if session is None or spec is None:

+ 77 - 23
src/step4_api/memory.py

@@ -1,8 +1,14 @@
-"""Conservative admission, not a replacement for a service cgroup memory limit."""
+"""Shared task slots and optional standalone memory diagnostics.
+
+Admission no longer samples memory or applies RSS/headroom budgets (F151).
+Service hard-limit checks and attachment worker limits are independent.
+"""
 
 from dataclasses import dataclass
-from pathlib import Path
+from mmap import PAGESIZE
+from pathlib import Path, PurePosixPath
 import os
+import re
 import sys
 from threading import Lock
 
@@ -15,10 +21,68 @@ class Memory:
     available: int
 
 
-def cgroup_memory(cgroup_file=Path('/proc/self/cgroup'), mount=Path('/sys/fs/cgroup')):
-    """Return the tightest visible v2 limit and remaining headroom."""
-    group = next((line[3:] for line in cgroup_file.read_text().splitlines()
-                  if line.startswith('0::')), None)
+def _mount_path(value):
+    # mountinfo encodes spaces, tabs, newlines and backslashes as octal escapes.
+    return re.sub(r'\\(040|011|012|134)', lambda m: chr(int(m[1], 8)), value)
+
+
+def _v1_memory(group, mountinfo):
+    """Read the memory hierarchy, including visible ancestor constraints."""
+    group = PurePosixPath(group)
+    if not group.is_absolute() or '..' in group.parts:
+        raise OSError('cgroup_path_unavailable')
+    limit, available, found = None, None, False
+    for line in mountinfo.read_text().splitlines():
+        before, separator, after = line.partition(' - ')
+        fields, filesystem = before.split(), after.split()
+        if not separator or len(fields) < 6 or len(filesystem) < 3:
+            continue
+        if filesystem[0] != 'cgroup' or 'memory' not in filesystem[2].split(','):
+            continue
+        root = PurePosixPath(_mount_path(fields[3]))
+        mount = Path(_mount_path(fields[4]))
+        if not root.is_absolute() or '..' in root.parts or not mount.is_absolute():
+            raise OSError('cgroup_mount_unavailable')
+        if group.is_relative_to(root):
+            relative = group.relative_to(root)
+        else:
+            # A cgroup namespace reports paths relative to its own root while
+            # mountinfo may still show the host hierarchy's bind-mount root.
+            relative = group.relative_to('/')
+        current = mount.joinpath(*relative.parts)
+        if not current.is_dir():
+            continue
+        found = True
+        while True:
+            maximum = int((current / 'memory.limit_in_bytes').read_text())
+            usage = int((current / 'memory.usage_in_bytes').read_text())
+            if maximum < 0 or usage < 0:
+                raise ValueError('invalid_cgroup_memory')
+            # v1 represents unlimited as LONG_MAX rounded down to a page.
+            if maximum < sys.maxsize // PAGESIZE * PAGESIZE:
+                remaining = max(0, maximum - usage)
+                limit = maximum if limit is None else min(limit, maximum)
+                available = remaining if available is None else min(available, remaining)
+            if current == mount:
+                break
+            current = current.parent
+    if not found:
+        raise OSError('cgroup_memory_mount_unavailable')
+    return limit, available
+
+
+def cgroup_memory(cgroup_file=Path('/proc/self/cgroup'), mount=Path('/sys/fs/cgroup'),
+                  mountinfo=Path('/proc/self/mountinfo'), *, require_v2=False):
+    """Return the tightest visible memory-controller limit and headroom."""
+    rows = [line.split(':', 2) for line in cgroup_file.read_text().splitlines()]
+    # In hybrid hierarchies memory may belong to v1 even when 0:: also exists.
+    memory_group = next((row[2] for row in rows if len(row) == 3
+                         and 'memory' in row[1].split(',')), None)
+    if memory_group is not None:
+        if require_v2:
+            raise OSError('cgroup_v2_required')
+        return _v1_memory(memory_group, mountinfo)
+    group = next((row[2] for row in rows if len(row) == 3 and row[:2] == ['0', '']), None)
     if group is None:
         raise OSError('cgroup_v2_required')
     current = mount / group.lstrip('/')
@@ -44,20 +108,20 @@ def require_hard_limit():
     if os.environ.get('API_REQUIRE_MEMORY_CGROUP') == '1':
         if not sys.platform.startswith('linux'):
             raise RuntimeError('finite_memory_cgroup_required')
-        limit, _ = cgroup_memory()
+        limit, _ = cgroup_memory(require_v2=True)
         expected = int(os.environ['API_MEMORY_HARD_LIMIT_MB']) * MIB
         if limit is None or not 0 < limit <= expected:
             raise RuntimeError('finite_memory_cgroup_required')
 
 
-def read_memory():
+def read_memory(proc=Path('/proc')):
     if sys.platform.startswith('linux'):
-        fields = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
+        fields = dict(line.split(':', 1) for line in (proc / 'meminfo').read_text().splitlines())
         available = int(fields['MemAvailable'].split()[0]) * 1024
-        rss = int(Path('/proc/self/statm').read_text().split()[1]) * os.sysconf('SC_PAGE_SIZE')
+        rss = int((proc / 'self/statm').read_text().split()[1]) * PAGESIZE
         # Host availability alone is unsafe inside a smaller cgroup. Walk all
         # visible ancestors, including the mount root (cgroup namespaces).
-        _, headroom = cgroup_memory()
+        _, headroom = cgroup_memory(proc / 'self/cgroup', mountinfo=proc / 'self/mountinfo')
         if headroom is not None:
             available = min(available, headroom)
         return Memory(rss, available)
@@ -90,6 +154,8 @@ def read_memory():
 
 class Admission:
     def __init__(self, settings, reader=read_memory):
+        # Preserve the constructor for existing runtime/test callers. The
+        # reader is diagnostic only and is never invoked when admitting work.
         self.settings, self.reader = settings, reader
         self.lock = Lock()
         self.active = 0
@@ -98,18 +164,6 @@ class Admission:
         with self.lock:
             if self.active >= self.settings.memory_max_jobs:
                 return None, 'server_busy'
-            try:
-                sample = self.reader()
-                if sample.rss <= 0 or sample.available < 0:
-                    raise ValueError()
-            except Exception:
-                return None, 'memory_status_unavailable'
-            # Reservations are deliberately additional to RSS: running jobs may
-            # still grow. This is an admission estimate, not a per-job allocator.
-            reserved = (self.active + 1) * self.settings.memory_job_reserve_mb * MIB
-            if (sample.rss + reserved > self.settings.memory_budget_mb * MIB or
-                    sample.available < self.settings.memory_min_available_mb * MIB + reserved):
-                return None, 'memory_busy'
             self.active += 1
         return Lease(self), None
 

+ 29 - 12
src/step4_api/tests/test_memory.py

@@ -36,20 +36,20 @@ class MemoryTests(unittest.TestCase):
         self.assertEqual(admission.active, 0)
         self.assertIsNotNone(admission.acquire()[0])
 
-    def test_rss_headroom_unknown_readings_and_recovery(self):
+    def test_admission_never_samples_memory_even_if_unavailable_or_over_budget(self):
         admission = gate(memory_budget_mb=1024)
-        for sample, error in [(Memory(900 * MIB, 8192 * MIB), 'memory_busy'),
-                              (Memory(512 * MIB, 700 * MIB), 'memory_busy'),
-                              (Memory(0, 8192 * MIB), 'memory_status_unavailable')]:
-            admission.reader = lambda: sample
-            self.assertEqual(admission.acquire(), (None, error))
+        for sample in (Memory(4096 * MIB, 8192 * MIB), Memory(512 * MIB, 0), Memory(0, -1)):
+            admission.reader = Mock(return_value=sample)
+            lease, error = admission.acquire()
+            self.assertIsNone(error)
+            lease.release()
+            admission.reader.assert_not_called()
             self.assertEqual(admission.active, 0)
         admission.reader = Mock(side_effect=OSError('private details'))
-        self.assertEqual(admission.acquire(), (None, 'memory_status_unavailable'))
-        admission.reader = lambda: Memory(512 * MIB, 8192 * MIB)
         lease, error = admission.acquire()
         self.assertIsNone(error)
         lease.release()
+        admission.reader.assert_not_called()
 
     def test_disconnect_does_not_free_shared_classification_capacity(self):
         admission = gate(memory_max_jobs=1)
@@ -167,20 +167,37 @@ class MemoryTests(unittest.TestCase):
 
 
 class HttpMemoryTests(unittest.IsolatedAsyncioTestCase):
-    async def test_both_endpoints_report_pressure_without_running_work(self):
+    async def test_both_endpoints_preserve_shared_capacity_rejection(self):
         admission = gate()
         runtime = SimpleNamespace(admission=admission, close=lambda: None, run=Mock(), classify_company=Mock())
         app = create_app(Settings(), lambda _: runtime)
         async with app.router.lifespan_context(app):
             async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
-                admission.reader = lambda: Memory(4096 * MIB, 8192 * MIB)
+                leases = [admission.acquire()[0] for _ in range(admission.settings.memory_max_jobs)]
                 response = await client.post('/api/chat', json={'thread_id': 'test', 'question': 'synthetic'})
                 self.assertEqual(response.status_code, 429)
-                self.assertEqual(response.json(), {'error': 'memory_busy'})
+                self.assertEqual(response.json(), {'error': 'server_busy'})
                 self.assertEqual(response.headers['Retry-After'], '2')
                 with patch('src.step4_api.company_classification.validate_request', return_value={}):
                     response = await client.post('/api/company/classify', json={})
                 self.assertEqual(response.status_code, 429)
-                self.assertEqual(response.json(), {'error': 'memory_busy'})
+                self.assertEqual(response.json(), {'error': 'server_busy'})
                 runtime.run.assert_not_called()
                 runtime.classify_company.assert_not_called()
+                for lease in leases:
+                    lease.release()
+
+    async def test_classification_runs_when_memory_reader_would_fail(self):
+        reader = Mock(side_effect=OSError('cgroup_v2_required'))
+        admission = Admission(Settings(), reader)
+        runtime = SimpleNamespace(admission=admission, close=lambda: None, run=Mock(),
+                                  classify_company=Mock(return_value={'status': 'completed'}))
+        app = create_app(Settings(), lambda _: runtime)
+        async with app.router.lifespan_context(app):
+            async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
+                with patch('src.step4_api.company_classification.validate_request', return_value={}):
+                    response = await client.post('/api/company/classify', json={})
+                self.assertEqual(response.status_code, 200)
+                runtime.classify_company.assert_called_once()
+                reader.assert_not_called()
+                self.assertEqual(admission.active, 0)

+ 186 - 0
src/step4_api/tests/test_memory_cgroup_v1.py

@@ -0,0 +1,186 @@
+"""Synthetic proc/cgroup fixtures; no host configuration or external calls."""
+
+from mmap import PAGESIZE
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from types import SimpleNamespace
+import sys
+import unittest
+from unittest.mock import Mock, patch
+
+import httpx
+
+from src.step4_api.app import create_app
+from src.step4_api.config import Settings
+from src.step4_api.memory import Admission, MIB, cgroup_memory, read_memory, require_hard_limit
+
+
+class V1Fixture:
+    def __init__(self, directory, group='/user.slice/user-1000.slice/session-1.scope', root='/'):
+        self.proc = Path(directory) / 'proc'
+        (self.proc / 'self').mkdir(parents=True)
+        self.group_file = self.proc / 'self/cgroup'
+        self.mountinfo = self.proc / 'self/mountinfo'
+        self.mount = Path(directory) / 'memory controller'
+        self.mount.mkdir()
+        self.root = root
+        self.group_file.write_text(f'12:rdma:/\n6:memory:{group}\n1:name=systemd:/session\n')
+        self.write_mountinfo()
+        relative = group[len(root):] if root != '/' and group.startswith(root) else group
+        self.current = self.mount / relative.lstrip('/')
+        self.current.mkdir(parents=True, exist_ok=True)
+        node = self.current
+        while True:
+            self.set_memory(node, sys.maxsize // PAGESIZE * PAGESIZE, 0)
+            if node == self.mount:
+                break
+            node = node.parent
+        (self.proc / 'meminfo').write_text('MemTotal: 16777216 kB\nMemAvailable: 8388608 kB\n')
+        (self.proc / 'self/statm').write_text(f'999999 {128 * MIB // PAGESIZE} 0\n')
+
+    def write_mountinfo(self):
+        mount = str(self.mount).replace('\\', '/').replace(' ', r'\040')
+        self.mountinfo.write_text(f'33 22 0:29 {self.root} {mount} rw,nosuid - cgroup cgroup rw,memory\n')
+
+    @staticmethod
+    def set_memory(node, maximum, usage):
+        (node / 'memory.limit_in_bytes').write_text(str(maximum))
+        (node / 'memory.usage_in_bytes').write_text(str(usage))
+
+    def read_cgroup(self, **kwargs):
+        return cgroup_memory(self.group_file, mountinfo=self.mountinfo, **kwargs)
+
+
+class CgroupV1Tests(unittest.TestCase):
+    def fixture(self, **kwargs):
+        temp = TemporaryDirectory()
+        self.addCleanup(temp.cleanup)
+        return V1Fixture(temp.name, **kwargs)
+
+    def test_standalone_diagnostic_observes_parent_pressure(self):
+        tree = self.fixture()
+        tree.set_memory(tree.current, 2048 * MIB, 256 * MIB)
+        tree.set_memory(tree.current.parent, 4096 * MIB, 4000 * MIB)
+        self.assertEqual(tree.read_cgroup(), (2048 * MIB, 96 * MIB))
+        with patch('src.step4_api.memory.sys.platform', 'linux'):
+            self.assertEqual(read_memory(tree.proc).available, 96 * MIB)
+            tree.set_memory(tree.current.parent, 4096 * MIB, 512 * MIB)
+            self.assertEqual(read_memory(tree.proc).available, 1792 * MIB)
+
+    def test_unlimited_uses_host_available_and_has_no_finite_hard_limit(self):
+        tree = self.fixture()
+        self.assertEqual(tree.read_cgroup(), (None, None))
+        with patch('src.step4_api.memory.sys.platform', 'linux'):
+            sample = read_memory(tree.proc)
+        self.assertEqual((sample.rss, sample.available), (128 * MIB, 8192 * MIB))
+
+    def test_root_group_and_over_limit_usage(self):
+        tree = self.fixture(group='/')
+        tree.set_memory(tree.mount, 100 * MIB, 101 * MIB)
+        self.assertEqual(tree.read_cgroup(), (100 * MIB, 0))
+
+    def test_host_headroom_still_limits_large_cgroup(self):
+        tree = self.fixture()
+        tree.set_memory(tree.current, 16 * 1024 * MIB, MIB)
+        with patch('src.step4_api.memory.sys.platform', 'linux'):
+            self.assertEqual(read_memory(tree.proc).available, 8192 * MIB)
+
+    def test_combined_controller_and_hybrid_prefers_memory_v1(self):
+        tree = self.fixture(group='/service')
+        tree.group_file.write_text('0::/unified\n6:cpu,memory:/service\n')
+        tree.set_memory(tree.current, 900 * MIB, 100 * MIB)
+        self.assertEqual(tree.read_cgroup(), (900 * MIB, 800 * MIB))
+
+    def test_bind_mount_root_and_namespaced_paths(self):
+        for group in ('/tenant/service', '/service', '/'):
+            with self.subTest(group=group):
+                tree = self.fixture(group=group, root='/tenant')
+                tree.set_memory(tree.current, 1500 * MIB, 200 * MIB)
+                self.assertEqual(tree.read_cgroup(), (1500 * MIB, 1300 * MIB))
+
+    def test_unrelated_mounts_are_ignored(self):
+        tree = self.fixture()
+        prefix = '44 22 0:30 / /missing rw - cgroup cgroup rw,cpu\n'
+        tree.mountinfo.write_text(prefix + tree.mountinfo.read_text())
+        self.assertEqual(tree.read_cgroup(), (None, None))
+
+    def test_missing_mount_or_group_is_not_host_only_fallback(self):
+        for kind in ('mount', 'group', 'controller', 'traversal'):
+            with self.subTest(kind=kind):
+                tree = self.fixture()
+                if kind == 'mount':
+                    tree.mountinfo.write_text('')
+                elif kind == 'group':
+                    tree.group_file.write_text('6:memory:/missing\n')
+                elif kind == 'controller':
+                    tree.group_file.write_text('6:cpu:/\n')
+                else:
+                    tree.group_file.write_text('6:memory:/../outside\n')
+                with self.assertRaises(OSError):
+                    tree.read_cgroup()
+
+    def test_standalone_diagnostic_reports_corrupt_missing_or_denied_accounting(self):
+        for kind in ('missing', 'bad_limit', 'negative_limit', 'negative_usage', 'denied'):
+            with self.subTest(kind=kind):
+                tree = self.fixture()
+                path = tree.current / 'memory.usage_in_bytes'
+                if kind == 'missing':
+                    path.unlink()
+                elif kind == 'bad_limit':
+                    (tree.current / 'memory.limit_in_bytes').write_text('invalid')
+                elif kind == 'negative_limit':
+                    tree.set_memory(tree.current, -1, 0)
+                elif kind == 'negative_usage':
+                    tree.set_memory(tree.current, 100, -1)
+                with patch('src.step4_api.memory.sys.platform', 'linux'):
+                    if kind == 'denied':
+                        with patch.object(Path, 'read_text', side_effect=PermissionError()):
+                            with self.assertRaises(PermissionError):
+                                read_memory(tree.proc)
+                    else:
+                        with self.assertRaises((OSError, ValueError)):
+                            read_memory(tree.proc)
+
+    def test_v1_sampling_does_not_claim_v2_service_isolation(self):
+        tree = self.fixture()
+        tree.set_memory(tree.current, 2048 * MIB, 100 * MIB)
+        self.assertEqual(tree.read_cgroup(), (2048 * MIB, 1948 * MIB))
+        with patch.dict('os.environ', {'API_REQUIRE_MEMORY_CGROUP': '1', 'API_MEMORY_HARD_LIMIT_MB': '4096'}), \
+                patch('src.step4_api.memory.sys.platform', 'linux'), \
+                patch('src.step4_api.memory.cgroup_memory', side_effect=tree.read_cgroup):
+            with self.assertRaisesRegex(OSError, 'cgroup_v2_required'):
+                require_hard_limit()
+
+
+class V1HttpTests(unittest.IsolatedAsyncioTestCase):
+    async def test_questions_run_without_memory_sampling_even_when_files_break(self):
+        with TemporaryDirectory() as directory:
+            tree = V1Fixture(directory)
+            reader = Mock(side_effect=lambda: read_memory(tree.proc))
+            admission = Admission(Settings(), reader)
+
+            def run(thread_id, question, emit, *args, **kwargs):
+                emit('result', {'response': 'synthetic answer'})
+                emit('done', {})
+
+            runtime = SimpleNamespace(admission=admission, close=lambda: None, run=Mock(side_effect=run))
+            app = create_app(Settings(), lambda _: runtime)
+            with patch('src.step4_api.memory.sys.platform', 'linux'):
+                async with app.router.lifespan_context(app):
+                    async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
+                        questions = ('我想在青浦开一家餐饮店,需要办理哪些手续?', '个人租房有什么支持?', '你好')
+                        for i, question in enumerate(questions):
+                            response = await client.post('/api/chat', json={'thread_id': f'v1-{i}', 'question': question})
+                            self.assertEqual(response.status_code, 200)
+                            self.assertIn('event: result', response.text)
+                        self.assertEqual(runtime.run.call_count, len(questions))
+                        tree.set_memory(tree.current, 400 * MIB, 350 * MIB)
+                        response = await client.post('/api/chat', json={'thread_id': 'low', 'question': 'test'})
+                        self.assertEqual(response.status_code, 200)
+                        self.assertIn('event: result', response.text)
+                        (tree.current / 'memory.limit_in_bytes').unlink()
+                        response = await client.post('/api/chat', json={'thread_id': 'failed', 'question': 'test'})
+                        self.assertEqual(response.status_code, 200)
+                        self.assertIn('event: result', response.text)
+                        self.assertEqual(runtime.run.call_count, len(questions) + 2)
+                        reader.assert_not_called()

+ 23 - 4
src/step5_session_analysis/dms.py

@@ -49,6 +49,7 @@ class DmsConfig:
     schema: dict = field(default_factory=lambda: DEFAULT_SCHEMA, repr=False)
     auth_port: int | None = None
     dms_port: int | None = None
+    login_url: str = field(default='', repr=False)
 
     def alias_for(self, identity):
         ids = {'records': self.records_id, 'sessions': self.sessions_id, 'demands': self.demands_id,
@@ -65,7 +66,7 @@ class DmsConfig:
 
 
 def load_dms_config(path=ROOT / '.env', environ=None, *, scope_names=None, schema_path=None):
-    names = {'DMS_BASE_URL', 'DMS_USERNAME', 'DMS_PASSWORD', 'DMS_TOKEN',
+    names = {'DMS_BASE_URL', 'DMS_LOGIN_URL', 'DMS_USERNAME', 'DMS_PASSWORD', 'DMS_TOKEN',
              'DMS_RECORDS_COLUMN_ID', 'DMS_SESSIONS_COLUMN_ID', 'DMS_STRICT_SCOPE', 'DMS_ALLOWED_SCOPES_JSON',
              'DMS_SCAN_TIMEOUT_SECONDS', 'DMS_DEMANDS_COLUMN_ID', 'AUTH_PORT', 'DMS_PORT'}
     values = {}
@@ -100,7 +101,8 @@ def load_dms_config(path=ROOT / '.env', environ=None, *, scope_names=None, schem
                            json.loads(values.get('DMS_ALLOWED_SCOPES_JSON', '{}')),
                            int(values.get('DMS_SCAN_TIMEOUT_SECONDS', '300')), tuple(scope_names),
                            int(values.get('DMS_DEMANDS_COLUMN_ID', schema_column(schema, 'demands')['id'])), schema,
-                           auth_port, dms_port)
+                           auth_port, dms_port, values.get('DMS_LOGIN_URL', ''))
+        connection_urls(config)  # Validate explicit login URL before credentials can be sent.
         if config.records_id <= 0 or config.sessions_id <= 0 or config.records_id == config.sessions_id:
             raise ValueError()
         if not 30 <= config.scan_timeout_seconds <= 900:
@@ -135,9 +137,26 @@ def connection_urls(config):
     def endpoint(port):
         actual = parsed.port if port is None else port
         return origin + (f':{actual}' if actual is not None else '')
-    api = endpoint(config.dms_port) + (parsed.path or '/proxy_dms')
+    # A bare host with both service ports addresses the services directly.
+    # Explicit /proxy_dms retains the gateway contract, regardless of ports.
+    split_direct = not parsed.path.rstrip('/') and config.auth_port is not None and config.dms_port is not None
+    api = endpoint(config.dms_port) + (parsed.path.rstrip('/') or ('/dms' if split_direct else '/proxy_dms'))
     login = (None if parsed.path == '/dms' and config.auth_port is None else
-             endpoint(config.auth_port) + '/proxy_oauth/user/login')
+             endpoint(config.auth_port) + ('/oauth/api/user/login' if split_direct else '/proxy_oauth/user/login'))
+    if config.login_url:
+        explicit = config.login_url
+        try:
+            target = urlsplit(explicit)
+            if (any(c.isspace() or ord(c) < 33 for c in explicit) or '\\' in explicit
+                    or target.scheme not in ('http', 'https') or not target.hostname
+                    or target.username is not None or target.password is not None
+                    or target.query or target.fragment or not target.path or target.path == '/'):
+                raise ValueError()
+            if target.port is not None and not 1 <= target.port <= 65535:
+                raise ValueError()
+        except (TypeError, ValueError):
+            raise DmsError('invalid_dms_login_url') from None
+        login = explicit
     return api, login
 
 

+ 34 - 3
src/step5_session_analysis/tests/test_dms_connection_modes.py

@@ -13,7 +13,7 @@ from src.step5_session_analysis.dms import DmsClient, DmsError, load_dms_config
 
 MODES = (
     ('http://example.invalid:8080', '', '', '/proxy_dms', 8080, 8080),
-    ('http://example.invalid', '10086', '10081', '/proxy_dms', 10086, 10081),
+    ('http://example.invalid', '10086', '10081', '/dms', 10086, 10081),
     ('http://example.invalid:8080/proxy_dms', '10086', '', '/proxy_dms', 10086, 8080),
     ('http://example.invalid:8080', '', '10081', '/proxy_dms', 8080, 10081),
     ('http://example.invalid:8080/dms', '10086', '10081', '/dms', 10086, 10081),
@@ -38,6 +38,37 @@ def config_for(base, auth, data, prefixed, company=False, token=''):
 
 
 class ConnectionModeTests(unittest.TestCase):
+    def test_explicit_login_url_overrides_auth_port_and_refreshes(self):
+        from dataclasses import replace
+        for company in (False, True):
+            config = config_for('http://example.invalid:10081/dms', '', '', False, company, token='expired')
+            config = replace(config, login_url='http://auth.example.invalid:8888/oauth/api/user/login', auth_port=9999)
+            calls = []
+            def send(url, form, headers):
+                calls.append(url)
+                if url == config.login_url:
+                    return {'code': 200, 'message': 'renewed'}
+                return {'code': 212} if headers['token'] == 'expired' else {'code': 200, 'content': {}}
+            client = (DmsCompanyClient if company else DmsClient)(config, send)
+            client.call('/column/getColumnById', {'id': config.sessions_id})
+            self.assertEqual(calls, [client.api + '/column/getColumnById', config.login_url,
+                                    client.api + '/column/getColumnById'])
+            self.assertEqual(client.api, 'http://example.invalid:10081/dms')
+
+    def test_explicit_url_loaded_and_invalid_values_rejected(self):
+        with TemporaryDirectory() as folder:
+            path = Path(folder) / '.env'
+            path.write_text('DMS_BASE_URL=http://example.invalid:10081/dms\n'
+                            'DMS_USERNAME=synthetic\nDMS_PASSWORD=synthetic\n'
+                            'DMS_LOGIN_URL=http://example.invalid:8888/oauth/api/user/login\n')
+            config = load_dms_config(path, {})
+            self.assertEqual(DmsClient(config).login_url, 'http://example.invalid:8888/oauth/api/user/login')
+            for invalid in ('http://user:secret@example.invalid/login', 'http://example.invalid/login?token=x',
+                            'http://example.invalid/login#fragment', 'http://example.invalid:99999/login',
+                            'http://example.invalid/a b', 'http://example.invalid/a\\b', '/relative/login'):
+                with self.subTest(invalid=invalid), self.assertRaisesRegex(DmsError, 'invalid_dms_login_url'):
+                    load_dms_config(path, {'DMS_LOGIN_URL': invalid})
+
     def test_same_config_keys_select_endpoints_and_both_field_layouts(self):
         for base, auth, data, api_path, auth_port, data_port in MODES:
             for prefixed in (False, True):
@@ -65,7 +96,7 @@ class ConnectionModeTests(unittest.TestCase):
                         client.columns[spec['id']] = {}
                         if auth_port is not None:
                             client._login()
-                            self.assertEqual(calls[0][0], f'http://example.invalid:{auth_port}/proxy_oauth/user/login')
+                            self.assertEqual(calls[0][0], f'http://example.invalid:{auth_port}' + ('/oauth/api/user/login' if base.rstrip('/') in ('http://example.invalid', 'http://example.invalid:8080') and auth and data else '/proxy_oauth/user/login'))
                             self.assertNotIn('token', calls[0][2])
                         rows = client.call('/content/selectContentList', {'columnId': spec['id'],
                             'search': json.dumps([{'field': logical, 'searchType': 1,
@@ -118,7 +149,7 @@ class ConnectionModeTests(unittest.TestCase):
                 else:
                     self.assertEqual(client.call('/column/getColumnById', {'id': config.sessions_id}), 'ok')
                     self.assertEqual(len(calls), 3)
-                    self.assertEqual(calls[1], f'http://example.invalid:{auth_port}/proxy_oauth/user/login')
+                    self.assertEqual(calls[1], f'http://example.invalid:{auth_port}' + ('/oauth/api/user/login' if base.rstrip('/') in ('http://example.invalid', 'http://example.invalid:8080') and auth and data else '/proxy_oauth/user/login'))
                     self.assertEqual(calls[0], calls[2])
 
     def test_direct_api_without_auth_port_never_guesses_a_login_address(self):

+ 5 - 5
src/step5_session_analysis/tests/test_dms_ports.py

@@ -27,8 +27,8 @@ class DmsPortTests(unittest.TestCase):
         client = DmsClient(config, transport)
         client._login()
         self.assertEqual(client.call('/column/getColumnById', {'id': 3037}), {'id': 3037})
-        self.assertEqual(requests[0][0], 'http://example.invalid:10086/proxy_oauth/user/login')
-        self.assertEqual(requests[1][0], 'http://example.invalid:10081/proxy_dms/column/getColumnById')
+        self.assertEqual(requests[0][0], 'http://example.invalid:10086/oauth/api/user/login')
+        self.assertEqual(requests[1][0], 'http://example.invalid:10081/dms/column/getColumnById')
         self.assertNotIn('token', requests[0][2])
         self.assertEqual(requests[1][2]['token'], 'synthetic-token')
 
@@ -60,9 +60,9 @@ class DmsPortTests(unittest.TestCase):
         self.assertEqual(DmsClient(config, transport).call('/column/getColumnById', {'id': 3037}),
                          {'id': 3037})
         self.assertEqual(requests, [
-            ('http://example.invalid:10081/proxy_dms/column/getColumnById', 'expired'),
-            ('http://example.invalid:10086/proxy_oauth/user/login', None),
-            ('http://example.invalid:10081/proxy_dms/column/getColumnById', 'renewed')])
+            ('http://example.invalid:10081/dms/column/getColumnById', 'expired'),
+            ('http://example.invalid:10086/oauth/api/user/login', None),
+            ('http://example.invalid:10081/dms/column/getColumnById', 'renewed')])
 
     def test_current_column_metadata_and_fields_are_checked_without_rows(self):
         with TemporaryDirectory() as directory: