wangxi пре 1 дан
родитељ
комит
9fc80a999f
4 измењених фајлова са 751 додато и 11 уклоњено
  1. 257 0
      API文件检测接口对接文档.md
  2. 81 0
      README.md
  3. 229 0
      scripts/tests/test_file_validation.py
  4. 184 11
      src/http_api.py

+ 257 - 0
API文件检测接口对接文档.md

@@ -0,0 +1,257 @@
+# Proposa AI 文件检测接口对接文档
+
+版本日期:2026-09-20。适用范围:当前仓库实现。
+
+本文路径、文件名、大小和摘要均为示例,不是线上检测结果。将 `https://api.example.com`
+替换为实际服务地址。后端入库前建议采用本文的 JSON 三字段请求。
+
+## 1. 接口信息
+
+| 项目 | 说明 |
+| --- | --- |
+| 用途 | 入库前检测文件是否为有效 DOCX 或可提取文字的 PDF |
+| 请求方法 | `POST` |
+| 请求路径 | `/api/v1/files/validate` |
+| 请求格式 | `application/json`;兼容 `multipart/form-data` 单文件上传 |
+| 响应格式 | `application/json`,直接返回对象,无 `code` / `data` 外层包装 |
+| 执行方式 | 同步检测,当前请求直接返回结果,不需要轮询 |
+| 鉴权 | 当前应用不要求 token;如部署网关设置鉴权,按网关要求提供 |
+| 其他参数 | 不需要 `txbId`、`CALLBACK_URL` 或生成任务参数 |
+
+本接口只检测文件,不负责入库,不创建投标生成任务,不调用 LLM、不执行 OCR、不发送回调。
+源文件保持不变,检测副本在成功或失败后自动清理。
+
+**放行规则:仅 HTTP 200 且响应 `valid=true` 时继续入库。其他状态或网络异常均不能视为通过。**
+
+## 2. JSON 请求体
+
+```json
+{
+  "file_path": "static/file/参考投书.docx",
+  "file_role": "REFERENCE_BID",
+  "is_absolute": false
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `file_path` | string | 是 | 服务端本地文件路径,不能为空;不是 HTTP URL,也不是调用方电脑上仅本机可见的路径 |
+| `file_role` | string | 是 | 业务用途,见下表;枚举值区分大小写 |
+| `is_absolute` | boolean | 是 | `true` 直接读取清理后的路径;`false` 根据业务用途拼接配置目录。必须传 JSON 布尔值,不能传字符串 `"false"` 或数字 0/1 |
+
+### 2.1 业务用途与目录
+
+| `file_role` | 业务用途 | `is_absolute=false` 时的目录 |
+| --- | --- | --- |
+| `TENDER_FILE` | 招标文件 | `TPC_DIR` |
+| `PROCUREMENT_FILE` | 采购需求 | `TPC_DIR` |
+| `CLA_FILE` | 澄清公告 | `TPC_DIR` |
+| `REFERENCE_BID` | 参考投书 | 依次查找 `REFERENCE_DIR`、`REFERENCE_DIR1` |
+
+`file_role` 是业务用途,`file_type` 是响应中的实际文件格式(`docx` / `pdf`),两者不同。
+**当前检测接口中,业务用途只决定路径解析规则,各用途均可检测 DOCX 或文字型 PDF。**
+原生成接口仍要求招标文件为文字 PDF、采购需求和参考投书为 DOCX;检测通过不代表已满足
+生成接口对应字段的格式要求。澄清公告在原生成接口中仍仅接收记录、不参与生成。
+
+### 2.2 路径解析顺序
+
+1. 去除 `file_path` 首尾空白,删除独立的 `static/` 或 `static\` 目录段。
+   `mystatic/`、文件名 `static.docx` 不受影响,配置目录本身不清理。
+2. `is_absolute=true`:按清理后的路径直接读取,不拼接业务目录。调用方应提供服务端绝对路径。
+   为兼容原接口,此模式不会额外拒绝相对文本;若误传相对路径,会按服务进程当前工作目录解析。
+3. `is_absolute=false`:去掉清理后路径开头的 `/` 或 `\`,按上表拼接配置目录。
+   即使传入路径以 `/` 开头,也不会自动改成绝对路径模式。
+4. 参考投书选择第一个实际存在的文件。两个目录都有文件时优先 `REFERENCE_DIR`;
+   第一份存在但内容不合法时,直接返回检测失败,不跳到第二份。
+5. 将选中的源路径转为完整绝对路径后检测,并在响应中返回。
+
+路径按 API 所在操作系统解释。Windows 路径建议使用 `E:/uploads/a.docx`,或在 JSON 中
+正确转义反斜杠。相对配置目录也按服务进程工作目录解析,生产环境建议配置绝对目录。
+
+示例配置(请替换为实际服务端配置):
+
+```dotenv
+TPC_DIR=/data/tender
+REFERENCE_DIR=/data/shenqin
+REFERENCE_DIR1=/data/dms/dms_upload
+```
+
+| 请求路径 | 用途 | 模式 | 解析结果 |
+| --- | --- | --- | --- |
+| `static/file/招标文件.pdf` | `TENDER_FILE` | false | `/data/tender/file/招标文件.pdf` |
+| `/static/file/采购需求.docx` | `PROCUREMENT_FILE` | false | `/data/tender/file/采购需求.docx` |
+| `static/file/参考投书.docx` | `REFERENCE_BID` | false | 先查 `/data/shenqin/file/参考投书.docx`,未找到再查 `/data/dms/dms_upload/file/参考投书.docx` |
+| `/data/uploads/澄清公告.pdf` | `CLA_FILE` | true | `/data/uploads/澄清公告.pdf` |
+
+### 2.3 调用示例
+
+Linux / macOS Shell:
+
+```bash
+curl -X POST "https://api.example.com/api/v1/files/validate" \
+  -H "Content-Type: application/json" \
+  --data '{"file_path":"static/file/参考投书.docx","file_role":"REFERENCE_BID","is_absolute":false}'
+```
+
+PowerShell:
+
+```powershell
+$body = @{
+    file_path = "static/file/参考投书.docx"
+    file_role = "REFERENCE_BID"
+    is_absolute = $false
+} | ConvertTo-Json
+Invoke-RestMethod -Method Post `
+    -Uri "https://api.example.com/api/v1/files/validate" `
+    -ContentType "application/json; charset=utf-8" `
+    -Body ([System.Text.Encoding]::UTF8.GetBytes($body))
+```
+
+## 3. 成功响应
+
+HTTP 200,DOCX 示例:
+
+```json
+{
+  "valid": true,
+  "file_type": "docx",
+  "is_text_pdf": null,
+  "file_role": "REFERENCE_BID",
+  "resolved_path": "/data/shenqin/file/参考投书.docx",
+  "filename": "参考投书.docx",
+  "size": 12345,
+  "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+  "message": "文件检测通过"
+}
+```
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `valid` | boolean | 成功时为 true |
+| `file_type` | string | `docx` 或 `pdf` |
+| `is_text_pdf` | boolean / null | PDF 成功时为 true;DOCX 为 null |
+| `file_role` | string | 请求中的业务用途,仅 JSON 路径模式返回 |
+| `resolved_path` | string | 实际选中源文件的完整路径,仅 JSON 路径模式成功时返回 |
+| `filename` | string | 文件名,不含目录 |
+| `size` | integer | 检测文件大小,单位字节 |
+| `sha256` | string | 检测内容的 SHA-256,64 位十六进制字符串 |
+| `message` | string | 展示或排查用提示语,不要按文案字符串做业务判断 |
+
+PDF 成功时 `file_type="pdf"`、`is_text_pdf=true`,其余字段规则一致。
+检测后应将同一份内容入库;文件可能被替换时,可用摘要确认内容一致。
+
+## 4. 失败响应与完整路径
+
+可处理的检测/参数错误返回非 2xx 状态码,并统一包含以下字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `valid` | boolean | false |
+| `message` | string | 失败原因;能解析路径时包含完整源路径或所有完整候选路径 |
+| `detail` | string | 与 message 相同,兼容原有读取 detail 的调用方 |
+| `resolved_path` | string / null | 只有一个已确定源路径或候选时返回该完整路径;不代表文件一定存在。多个候选或无法解析时为 null |
+| `candidate_paths` | array[string] | 本次已确定的完整源路径或未找到文件时的完整候选列表;无法解析时为空数组 |
+
+`message` 中的完整路径是清理 `static`、拼接业务目录后的服务端路径,不是用户传入的
+部分路径,也不是内部检测临时副本路径。路径分隔符以实际服务端系统为准。
+
+### 4.1 内容不合法
+
+HTTP 400:
+
+```json
+{
+  "valid": false,
+  "message": "REFERENCE_BID 不是有效的 DOCX 文件;完整文件路径:/data/shenqin/file/参考投书.docx",
+  "detail": "REFERENCE_BID 不是有效的 DOCX 文件;完整文件路径:/data/shenqin/file/参考投书.docx",
+  "resolved_path": "/data/shenqin/file/参考投书.docx",
+  "candidate_paths": ["/data/shenqin/file/参考投书.docx"]
+}
+```
+
+无文字 PDF、文件为空、文件无法读取、格式不支持、文件超限等场景同样包含完整源路径,
+具体原因和 HTTP 状态码不同。
+
+### 4.2 参考投书两个目录都未找到
+
+HTTP 400(按上文示例目录配置):
+
+```json
+{
+  "valid": false,
+  "message": "REFERENCE_BID 在 REFERENCE_DIR 和 REFERENCE_DIR1 下均不存在: file/参考投书.docx;完整文件路径:/data/shenqin/file/参考投书.docx;/data/dms/dms_upload/file/参考投书.docx",
+  "detail": "REFERENCE_BID 在 REFERENCE_DIR 和 REFERENCE_DIR1 下均不存在: file/参考投书.docx;完整文件路径:/data/shenqin/file/参考投书.docx;/data/dms/dms_upload/file/参考投书.docx",
+  "resolved_path": null,
+  "candidate_paths": [
+    "/data/shenqin/file/参考投书.docx",
+    "/data/dms/dms_upload/file/参考投书.docx"
+  ]
+}
+```
+
+只配置了一个参考目录时,仅列出该目录对应的候选路径。
+
+### 4.3 无法确定完整路径
+
+例如相对模式未配置 `TPC_DIR`,HTTP 400:
+
+```json
+{
+  "valid": false,
+  "message": "TENDER_FILE 为相对路径,但未配置 TPC_DIR",
+  "detail": "TENDER_FILE 为相对路径,但未配置 TPC_DIR",
+  "resolved_path": null,
+  "candidate_paths": []
+}
+```
+
+必填参数缺失、业务用途无效、JSON 格式错误等发生在路径解析之前,也无法提供完整路径;
+服务会明确返回原因,不会猜测配置目录或虚构文件路径。
+
+## 5. HTTP 状态码
+
+| 状态码 | 含义及处理 |
+| --- | --- |
+| 200 | 检测通过;确认 valid=true 后继续入库 |
+| 400 | 文件不存在/无法读取/为空、后缀或内容无效、PDF 加密或无文字、路径配置缺失、JSON 语法错误等;读取 message 排查 |
+| 413 | 超过 `PROPOSA_API_MAX_UPLOAD_MB` 文件大小限制 |
+| 415 | 请求 Content-Type 不是 application/json 或 multipart/form-data |
+| 422 | JSON 三个必填字段缺失/类型不正确/用途无效,或上传字段缺失、不是文件、上传多个文件 |
+
+网络异常、网关错误或未处理的服务异常(例如 HTTP 500)不能视为检测通过;这些响应不保证
+包含本接口的 JSON 字段。建议保存 HTTP 状态码和响应正文,若存在 `message` 优先展示它。
+
+## 6. 检测范围与限制
+
+- 文件后缀仅允许 `.docx`、`.pdf`,不区分大小写;同时检查实际内容,不只检查后缀或 MIME。
+- DOCX 检查 ZIP 容器、必需 OOXML 部件、主文档内容类型、包关系及正文 XML;
+  改后缀的旧版 `.doc`、普通 ZIP、无效主文档会被拒绝。DOCX 不要求包含文字。
+- PDF 必须未加密、可逐页解析,并至少一页可提取非空文字;纯扫描或无文字 PDF 不通过。
+- 混合文字/扫描页 PDF、有 OCR 文字层的 PDF 可能通过;不保证每页都有文字,
+  不判断 OCR 正确率,也不判断业务内容是否完整。
+- 文件大小沿用 `PROPOSA_API_MAX_UPLOAD_MB`,默认 512 MB;JSON 本地路径也受此限制。
+  PDF 检测会读入内存,服务端应按容量设置上限。
+- 后续生成接口仍会再次校验文件。本接口通过不保证后续 Step1-6 一定成功。
+
+## 7. 兼容的文件上传方式
+
+无需服务端文件路径时,可使用 `multipart/form-data` 上传一个小写 `file` 字段。
+该模式不需要 `file_path`、`file_role`、`is_absolute`,不参与业务目录拼接。
+
+```bash
+curl -X POST "https://api.example.com/api/v1/files/validate" -F "file=@待检测文件.pdf"
+```
+
+成功字段与第 3 节相同,但不包含 `file_role` 和 `resolved_path`。
+失败使用第 4 节的统一结构;由于不存在服务端源路径,`resolved_path=null`、
+`candidate_paths=[]`,message 仅包含失败原因,不暴露内部临时路径。
+
+## 8. 后端接入顺序
+
+1. 将待检测文件放入 API 服务能够读取的存储位置。
+2. 根据文件业务用途发送 JSON 三字段请求,等待同步响应。
+3. HTTP 200 且 valid=true:继续业务入库;需要时保存 file_type、size、sha256。
+4. 其他响应:阻止入库,记录并展示 message;完整路径可直接用于核对目录、文件名及权限。
+5. 修正文件或路径配置后重新检测。检测和入库之间应保持文件内容一致。
+
+服务部署后可在 `/docs` 试用接口,`/openapi.json` 提供机器可读的请求契约。

+ 81 - 0
README.md

@@ -157,6 +157,7 @@ uv run proposa-api
 | 接口 | 方法 | 说明 |
 | --- | --- | --- |
 | `/health` | GET | 健康检查。 |
+| `/api/v1/files/validate` | POST | 上传单个文件,同步检测 DOCX / 文字型 PDF,供入库前校验。 |
 | `/api/v1/final-review` | POST | 提交生成任务,返回 HTTP 202。 |
 | `/api/v1/jobs/{request_id}` | GET | 查询任务状态。 |
 | `/api/v1/jobs/{request_id}/progress` | GET | 查询 Step1-6 进度。 |
@@ -179,6 +180,86 @@ GET /api/v1/jobs/TEST-TXB-001/file
 映射和任务记录均为进程内存状态,服务重启后不保留;多实例需访问持有任务的实例。
 完整示例见 [API 进度查询接口对接文档](API进度查询接口对接文档.md)。
 
+### 入库前文件检测
+
+完整字段、路径规则和错误示例见 [文件检测接口对接文档](API文件检测接口对接文档.md)。
+
+`POST /api/v1/files/validate` 支持 JSON 服务端路径和 multipart 文件上传。
+每次只检测一个文件,不需要 `txbId`、回调地址或本地模板,不创建任务、不调用 LLM。
+
+JSON 请求包含三个必填字段:
+
+```json
+{
+  "file_path": "static/file/参考投书.docx",
+  "file_role": "REFERENCE_BID",
+  "is_absolute": false
+}
+```
+
+| 字段 | 说明 |
+| --- | --- |
+| `file_path` | 非空字符串,服务端文件路径,不是 HTTP 下载地址。 |
+| `file_role` | `TENDER_FILE` 招标文件、`PROCUREMENT_FILE` 采购需求、`CLA_FILE` 澄清公告、`REFERENCE_BID` 参考投书。 |
+| `is_absolute` | 必填布尔值;true 直接读取清理后的路径,false 按业务用途拼接目录。 |
+
+路径规则与原生成接口共用:先清理独立 `static` 目录段;相对模式下,前三类拼接
+`TPC_DIR`,参考投书依次在 `REFERENCE_DIR`、`REFERENCE_DIR1` 下查找,首个存在文件优先。
+成功响应额外包含 `file_role` 和 `resolved_path`(实际完整源文件路径)。业务用途决定
+路径规则;本检测接口允许各用途的有效 DOCX 或文字 PDF,生成接口的业务字段格式限制仍保留。
+
+失败仍返回非 2xx HTTP 状态,包含顶层 `valid=false`、`message`、兼容字段 `detail`、
+`resolved_path` 和 `candidate_paths`。文件不存在、损坏、无文字、超限或无法读取时,
+`message` 包含解析后的完整源路径,不返回检测临时副本路径。例如:
+
+```json
+{
+  "valid": false,
+  "message": "REFERENCE_BID 不是有效的 DOCX 文件;完整文件路径:/data/shenqin/file/参考投书.docx",
+  "detail": "REFERENCE_BID 不是有效的 DOCX 文件;完整文件路径:/data/shenqin/file/参考投书.docx",
+  "resolved_path": "/data/shenqin/file/参考投书.docx",
+  "candidate_paths": ["/data/shenqin/file/参考投书.docx"]
+}
+```
+
+参考投书所有配置目录均未找到文件时,`message` 和 `candidate_paths` 列出所有完整候选路径,
+多个候选时 `resolved_path=null`。参数无效或目录未配置、无法确定完整路径时,明确返回原因,
+不虚构完整路径(`resolved_path=null`、`candidate_paths=[]`)。
+
+multipart 模式仍使用小写 `file` 上传;该模式无服务端源路径,失败不返回临时路径:
+
+```powershell
+curl.exe -X POST "http://127.0.0.1:8000/api/v1/files/validate" -F "file=@待上传文件.pdf"
+```
+
+检测通过返回 HTTP 200:
+
+```json
+{
+  "valid": true,
+  "file_type": "pdf",
+  "is_text_pdf": true,
+  "filename": "待上传文件.pdf",
+  "size": 12345,
+  "sha256": "文件内容的64位SHA-256摘要",
+  "message": "文件检测通过"
+}
+```
+
+- `file_type` 为 `pdf` 或 `docx`;DOCX 的 `is_text_pdf` 为 `null`。`size` 单位为字节。
+- 校验后缀和实际内容,不信任上传 MIME;后缀不区分大小写。不自动修正错误后缀。
+- DOCX 检查 ZIP、主文档内容类型、包关系及正文 XML;不要求 DOCX 有文字。
+- PDF 必须未加密、可逐页解析,且至少一页有可提取文字;无文字或纯扫描 PDF 拒绝。
+  混合 PDF、有 OCR 文字层的 PDF 可能通过;不保证每页都有文字,不验证 OCR 或业务内容准确性。
+- 沿用 `PROPOSA_API_MAX_UPLOAD_MB`(默认 512 MB);检测副本在响应前自动删除。
+  PDF 检测会将文件读入内存,请按服务容量配置大小上限。
+- 后端仅在 HTTP 200 且 `valid=true` 时继续入库,并确保入库内容与检测内容一致(可比对哈希)。
+  生成接口仍会再次校验文件和对应业务字段要求。
+
+HTTP 400 表示文件/路径无效、目录未配置、空文件、不支持的后缀、结构无效、加密或无文字 PDF,
+也用于无效 JSON;413 表示超限;415 表示请求不是 multipart 或 JSON;422 表示 JSON 必填参数
+缺失/类型错误/用途无效,或上传缺少 `file`、字段不是文件、上传了多个文件。可在 `/docs` 查看并试用。
+
 ### 请求格式
 
 接口同时支持 `multipart/form-data` 文件上传和 `application/json` 服务端本地路径。

+ 229 - 0
scripts/tests/test_file_validation.py

@@ -0,0 +1,229 @@
+"""Upload preflight uses real synthetic documents, without LLM or callbacks."""
+
+import hashlib
+from io import BytesIO
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+import zipfile
+
+import pymupdf
+from fastapi.testclient import TestClient
+from fastapi import HTTPException
+
+import http_api
+from scripts.tests.test_api import _docx_bytes, _pdf_bytes
+
+
+class FileValidationTests(unittest.TestCase):
+    def setUp(self):
+        temp = tempfile.TemporaryDirectory()
+        self.addCleanup(temp.cleanup)
+        self.root = Path(temp.name)
+        self.log = self.root / "requests.log"
+        for target, value in [
+            ("request_logging.LOG_PATH", self.log),
+            ("http_api.tempfile.tempdir", temp.name),
+        ]:
+            patcher = patch(target, value)
+            patcher.start()
+            self.addCleanup(patcher.stop)
+        self.client = TestClient(http_api.app)
+
+    def post(self, name, content, mime="application/octet-stream"):
+        jobs = dict(http_api._JOBS)
+        with patch.object(http_api._JOB_EXECUTOR, "submit") as submit:
+            result = self.client.post("/api/v1/files/validate", files={"file": (name, content, mime)})
+            submit.assert_not_called()
+        self.assertEqual(http_api._JOBS, jobs, "检测接口不得创建任务")
+        self.assertEqual(list(self.root.glob("proposa-validate-*")), [], "检测副本必须清理")
+        return result
+
+    def test_valid_docx_and_text_pdf_metadata_and_audit(self):
+        for name, content, kind in [("文件.DOCX", _docx_bytes(), "docx"), ("文件.PDF", _pdf_bytes(), "pdf")]:
+            with self.subTest(kind=kind):
+                response = self.post(name, content, "image/png")
+                self.assertEqual(response.status_code, 200, response.text)
+                data = response.json()
+                self.assertTrue(data["valid"])
+                self.assertEqual(data["file_type"], kind)
+                self.assertEqual(data["filename"], name)
+                self.assertEqual(data["size"], len(content))
+                self.assertEqual(data["sha256"], hashlib.sha256(content).hexdigest())
+                self.assertIs(data["is_text_pdf"], True if kind == "pdf" else None)
+        records = [json.loads(line) for line in self.log.read_text(encoding="utf-8").splitlines()]
+        request = next(record for record in records if record["event"] == "request")
+        self.assertEqual(request["body"][0]["name"], "file")
+        self.assertNotIn("tender text", self.log.read_text(encoding="utf-8"))
+
+    def test_invalid_empty_disguised_and_unsupported(self):
+        for name, content in [
+            ("a.doc", _docx_bytes()), ("a.txt", b"text"),
+            ("a.pdf", b""), ("a.docx", b""),
+            ("a.pdf", b"broken"), ("a.docx", b"broken"),
+            ("a.pdf", _docx_bytes()), ("a.docx", _pdf_bytes()),
+        ]:
+            with self.subTest(name=name, size=len(content)):
+                response = self.post(name, content)
+                self.assertEqual(response.status_code, 400, response.text)
+                self.assertIn("detail", response.json())
+
+    def test_pdf_blank_image_only_and_encrypted(self):
+        with pymupdf.open() as doc:
+            page = doc.new_page()
+            pixmap = pymupdf.Pixmap(pymupdf.csRGB, (0, 0, 20, 20), False)
+            pixmap.clear_with(255)
+            page.insert_image(page.rect, pixmap=pixmap)
+            scanned = doc.tobytes()
+        with pymupdf.open(stream=_pdf_bytes(), filetype="pdf") as doc:
+            encrypted = doc.tobytes(encryption=pymupdf.PDF_ENCRYPT_AES_256,
+                                   owner_pw="owner", user_pw="user")
+        for content in [_pdf_bytes(""), scanned, encrypted]:
+            self.assertEqual(self.post("a.pdf", content).status_code, 400)
+
+    def test_mixed_pdf_uses_existing_at_least_one_text_page_rule(self):
+        with pymupdf.open() as doc:
+            doc.new_page()
+            doc.new_page().insert_text((72, 72), "extractable text")
+            content = doc.tobytes()
+        self.assertEqual(self.post("mixed.pdf", content).status_code, 200)
+
+    def test_docx_container_xml_content_type_and_relationships(self):
+        for part, replacement in [
+            ("word/document.xml", b"<root/>"),
+            ("word/document.xml", b"<broken"),
+            ("[Content_Types].xml", b"<Types/>"),
+            ("_rels/.rels", b"<Relationships/>"),
+            ("word/document.xml", None),
+        ]:
+            with self.subTest(part=part, replacement=replacement):
+                output = BytesIO()
+                with zipfile.ZipFile(BytesIO(_docx_bytes())) as source, zipfile.ZipFile(output, "w") as target:
+                    for name in source.namelist():
+                        if name == part:
+                            if replacement is not None:
+                                target.writestr(name, replacement)
+                        else:
+                            target.writestr(name, source.read(name))
+                self.assertEqual(self.post("a.docx", output.getvalue()).status_code, 400)
+
+    def test_size_limit_and_exact_boundary(self):
+        content = _pdf_bytes()
+        with patch.object(http_api, "MAX_UPLOAD_BYTES", len(content)):
+            self.assertEqual(self.post("a.pdf", content).status_code, 200)
+        with patch.object(http_api, "MAX_UPLOAD_BYTES", len(content) - 1):
+            self.assertEqual(self.post("a.pdf", content).status_code, 413)
+
+    def test_request_contract_and_openapi(self):
+        url = "/api/v1/files/validate"
+        self.assertEqual(self.client.post(url, content="text", headers={"Content-Type": "text/plain"}).status_code, 415)
+        for files in [
+            [("other", ("a.pdf", _pdf_bytes()))],
+            [("file", (None, "not an upload"))],
+            [("file", ("a.pdf", _pdf_bytes())), ("file", ("b.pdf", _pdf_bytes()))],
+            [("file", ("a.pdf", _pdf_bytes())), ("other", ("b.pdf", _pdf_bytes()))],
+        ]:
+            self.assertEqual(self.client.post(url, files=files).status_code, 422)
+        schema = self.client.get("/openapi.json").json()["paths"][url]["post"]
+        self.assertIn("multipart/form-data", schema["requestBody"]["content"])
+        self.assertEqual(schema["requestBody"]["content"]["application/json"]["schema"]["required"],
+                         ["file_path", "file_role", "is_absolute"])
+
+    def post_path(self, path, role="TENDER_FILE", absolute=False):
+        jobs = dict(http_api._JOBS)
+        with patch.object(http_api._JOB_EXECUTOR, "submit") as submit:
+            response = self.client.post("/api/v1/files/validate", json={
+                "file_path": str(path), "file_role": role, "is_absolute": absolute,
+            })
+            submit.assert_not_called()
+        self.assertEqual(http_api._JOBS, jobs)
+        self.assertEqual(list(self.root.glob("proposa-validate-*")), [])
+        return response
+
+    def test_json_tpc_roles_and_absolute_static_paths(self):
+        for role in ["TENDER_FILE", "PROCUREMENT_FILE", "CLA_FILE"]:
+            with self.subTest(role=role), patch.object(http_api, "TPC_DIR", str(self.root)):
+                path = self.root / "files" / "a.pdf"
+                path.parent.mkdir(exist_ok=True)
+                content = _pdf_bytes()
+                path.write_bytes(content)
+                response = self.post_path("/static/files/a.pdf", role)
+                self.assertEqual(response.status_code, 200, response.text)
+                self.assertEqual(response.json()["resolved_path"], str(path.resolve()))
+                self.assertEqual(response.json()["file_role"], role)
+                self.assertEqual(path.read_bytes(), content)
+                self.assertEqual(self.post_path(path.parent / "static" / path.name, role, True).status_code, 200)
+
+    def test_json_reference_fallback_and_priority(self):
+        first = self.root / "first"
+        second = self.root / "second"
+        first.mkdir()
+        second.mkdir()
+        content = _docx_bytes()
+        with patch.object(http_api, "REFERENCE_DIR", str(first)), patch.object(http_api, "REFERENCE_DIR1", str(second)):
+            response = self.post_path("static/missing.docx", "REFERENCE_BID")
+            self.assertEqual(response.status_code, 400)
+            expected = [str((directory / "missing.docx").resolve()) for directory in (first, second)]
+            self.assertEqual(response.json()["candidate_paths"], expected)
+            self.assertIsNone(response.json()["resolved_path"])
+            for path in expected:
+                self.assertIn(path, response.json()["message"])
+            (second / "a.docx").write_bytes(content)
+            response = self.post_path("static/a.docx", "REFERENCE_BID")
+            self.assertEqual(response.status_code, 200, response.text)
+            self.assertEqual(response.json()["resolved_path"], str((second / "a.docx").resolve()))
+            (first / "a.docx").write_bytes(b"broken")
+            response = self.post_path("static/a.docx", "REFERENCE_BID")
+            self.assertEqual(response.status_code, 400)
+            self.assertIn(str((first / "a.docx").resolve()), response.json()["message"])
+            self.assertNotIn(str(second), response.json()["message"])
+            self.assertEqual(self.post_path(second / "a.docx", "REFERENCE_BID", True).status_code, 200)
+
+    def test_json_all_file_failures_include_source_absolute_path(self):
+        for name, content in [("missing.pdf", None), ("empty.pdf", b""),
+                              ("bad.pdf", b"broken"), ("scan.pdf", _pdf_bytes("")),
+                              ("bad.docx", b"broken"), ("wrong.txt", b"text")]:
+            with self.subTest(name=name), patch.object(http_api, "TPC_DIR", str(self.root)):
+                path = self.root / name
+                if content is not None:
+                    path.write_bytes(content)
+                response = self.post_path("static/" + name)
+                self.assertEqual(response.status_code, 400, response.text)
+                body = response.json()
+                self.assertFalse(body["valid"])
+                self.assertIn(str(path.resolve()), body["message"])
+                self.assertEqual(body["resolved_path"], str(path.resolve()))
+                self.assertNotIn("proposa-validate-", body["message"])
+        path = self.root / "large.pdf"
+        path.write_bytes(_pdf_bytes())
+        with patch.object(http_api, "MAX_UPLOAD_BYTES", 1):
+            response = self.post_path(path, absolute=True)
+            self.assertEqual(response.status_code, 413)
+            self.assertIn(str(path.resolve()), response.json()["message"])
+        with patch.object(http_api, "_save_local_path", side_effect=HTTPException(400, "无法读取")):
+            response = self.post_path(path, absolute=True)
+            self.assertIn(str(path.resolve()), response.json()["message"])
+
+    def test_json_invalid_parameters_and_unconfigured_roots(self):
+        url = "/api/v1/files/validate"
+        valid = {"file_path": "a.pdf", "file_role": "TENDER_FILE", "is_absolute": False}
+        for body in [None, [], {}, {**valid, "file_path": 3}, {**valid, "file_path": " "},
+                     {**valid, "file_role": "unknown"}, {**valid, "is_absolute": "false"},
+                     {key: value for key, value in valid.items() if key != "is_absolute"}]:
+            response = self.client.post(url, content=json.dumps(body), headers={"Content-Type": "application/json"})
+            self.assertEqual(response.status_code, 422, response.text)
+            self.assertIn("message", response.json())
+        response = self.client.post(url, content="{", headers={"Content-Type": "application/json"})
+        self.assertEqual(response.status_code, 400)
+        with patch.object(http_api, "TPC_DIR", ""), patch.object(http_api, "REFERENCE_DIR", ""), patch.object(http_api, "REFERENCE_DIR1", ""):
+            for role in ("TENDER_FILE", "REFERENCE_BID"):
+                response = self.post_path("a.pdf", role)
+                self.assertEqual(response.status_code, 400)
+                self.assertIn("未配置", response.json()["message"])
+                self.assertEqual(response.json()["candidate_paths"], [])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 184 - 11
src/http_api.py

@@ -11,6 +11,7 @@ import re
 import shutil
 import subprocess
 import sys
+import tempfile
 import threading
 import time
 from urllib.parse import urlsplit
@@ -22,7 +23,9 @@ import zipfile
 
 from dotenv import load_dotenv
 from fastapi import FastAPI, HTTPException, Request, UploadFile
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, JSONResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.concurrency import run_in_threadpool
 from request_logging import RequestLogMiddleware, decode_body, write_event
 
 
@@ -119,40 +122,72 @@ def _save_upload(upload: UploadFile, target: Path, field_name: str) -> dict:
     }
 
 
-def _validate_text_pdf(path: Path) -> None:
+def _validate_text_pdf(path: Path, field_name: str = "TENDER_FILE") -> None:
     """要求 PDF 可解析且至少存在一个可提取的非空文字段落。"""
     try:
         import pymupdf
 
-        with pymupdf.open(path) as document:
-            if document.page_count == 0:
-                raise ValueError("PDF 没有页面")
+        # 从受大小限制的副本读取,避免 MuPDF 打开损坏文件失败时在 Windows
+        # 保留原生文件句柄,导致请求异常路径无法删除临时目录。
+        with pymupdf.open(stream=path.read_bytes(), filetype="pdf") as document:
+            if not document.is_pdf or document.is_encrypted or document.page_count == 0:
+                raise ValueError("不是未加密的有效 PDF")
+            has_text = False
             for page in document:
                 if page.get_text("text").strip():
-                    return
+                    has_text = True
+            if has_text:
+                return
     except Exception as exc:
         raise HTTPException(
             status_code=400,
-            detail="TENDER_FILE 不是可读取的 PDF 文件",
+            detail=f"{field_name} 不是可读取的未加密 PDF 文件",
         ) from exc
     raise HTTPException(
         status_code=400,
-        detail="TENDER_FILE 必须是可提取文字的文字类 PDF,不能是纯扫描 PDF",
+        detail=f"{field_name} 必须是可提取文字的文字类 PDF,不能是纯扫描 PDF",
     )
 
 
 def _validate_docx(path: Path, field_name: str) -> None:
     """验证 DOCX ZIP 结构及主文档 XML,拒绝改后缀或损坏文件。"""
-    required_parts = {"[Content_Types].xml", "word/document.xml"}
+    required_parts = {"[Content_Types].xml", "_rels/.rels", "word/document.xml"}
     try:
         if not zipfile.is_zipfile(path):
             raise ValueError("不是 ZIP 容器")
         with zipfile.ZipFile(path) as archive:
             if not required_parts.issubset(archive.namelist()):
                 raise ValueError("缺少 DOCX 必需部件")
+            content_types = ET.fromstring(archive.read("[Content_Types].xml"))
+            main_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
+            if not any(
+                item.get("PartName") == "/word/document.xml"
+                and item.get("ContentType") == main_type
+                for item in content_types
+            ):
+                raise ValueError("不是 DOCX 主文档类型")
+            relationships = ET.fromstring(archive.read("_rels/.rels"))
+            if not any(
+                item.get("Type") == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
+                and item.get("Target", "").lstrip("/") == "word/document.xml"
+                and item.get("TargetMode", "Internal") == "Internal"
+                for item in relationships
+            ):
+                raise ValueError("缺少主文档关系")
             with archive.open("word/document.xml") as document_xml:
-                for _event, element in ET.iterparse(document_xml, events=("end",)):
-                    element.clear()
+                namespace = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+                root_seen = False
+                body_seen = False
+                for event, element in ET.iterparse(document_xml, events=("start", "end")):
+                    if event == "start":
+                        if not root_seen and element.tag != namespace + "document":
+                            raise ValueError("主文档根元素错误")
+                        root_seen = True
+                        body_seen = body_seen or element.tag == namespace + "body"
+                    else:
+                        element.clear()
+                if not body_seen:
+                    raise ValueError("缺少文档正文")
     except Exception as exc:
         raise HTTPException(
             status_code=400,
@@ -564,6 +599,144 @@ def health() -> dict[str, str]:
     return {"status": "ok"}
 
 
+def _inspect_upload(upload: UploadFile) -> dict:
+    suffix = Path(upload.filename or "").suffix.lower()
+    if suffix not in {".docx", ".pdf"}:
+        raise HTTPException(status_code=400, detail="file 只支持 .docx 或 .pdf 文件")
+    # 固定内部文件名,不使用客户端路径;成功和异常均删除检测副本。
+    with tempfile.TemporaryDirectory(prefix="proposa-validate-") as temp_dir:
+        path = Path(temp_dir) / ("upload" + suffix)
+        metadata = _save_upload(upload, path, "file")
+        if suffix == ".pdf":
+            _validate_text_pdf(path, "file")
+        else:
+            _validate_docx(path, "file")
+        return {
+            "valid": True,
+            "file_type": suffix[1:],
+            "is_text_pdf": True if suffix == ".pdf" else None,
+            **metadata,
+            "message": "文件检测通过",
+        }
+
+
+_FILE_ROLES = ("TENDER_FILE", "PROCUREMENT_FILE", "CLA_FILE", "REFERENCE_BID")
+
+
+def _validation_failure(status: int, reason: str, paths: list[str] | None = None) -> JSONResponse:
+    paths = paths or []
+    message = reason
+    if paths:
+        message += ";完整文件路径:" + ";".join(paths)
+    return JSONResponse(status_code=status, content={
+        "valid": False, "message": message, "detail": message,
+        "resolved_path": paths[0] if len(paths) == 1 else None,
+        "candidate_paths": paths,
+    })
+
+
+def _inspect_local_file(payload: object) -> dict | JSONResponse:
+    paths: list[str] = []
+    try:
+        if not isinstance(payload, dict):
+            raise HTTPException(422, "请求体必须是 JSON 对象")
+        file_path = payload.get("file_path")
+        role = payload.get("file_role")
+        absolute = payload.get("is_absolute")
+        if not isinstance(file_path, str) or not file_path.strip():
+            raise HTTPException(422, "file_path 必须是非空字符串")
+        if role not in _FILE_ROLES:
+            raise HTTPException(422, "file_role 必须为 " + ", ".join(_FILE_ROLES))
+        if not isinstance(absolute, bool):
+            raise HTTPException(422, "is_absolute 必须显式提供布尔值")
+        file_path = file_path.strip()
+        if role == "REFERENCE_BID":
+            if not absolute:
+                relative = _strip_static_directory(file_path).lstrip("/\\")
+                paths = [str((Path(directory) / relative).resolve())
+                         for directory in (REFERENCE_DIR, REFERENCE_DIR1) if directory]
+            source = _resolve_reference_path(file_path, absolute)
+        else:
+            source = _resolve_tpc_path(file_path, absolute, role)
+        source_path = Path(source).resolve()
+        paths = [str(source_path)]
+        suffix = source_path.suffix.lower()
+        if suffix not in {".docx", ".pdf"}:
+            raise HTTPException(400, "file 只支持 .docx 或 .pdf 文件")
+        with tempfile.TemporaryDirectory(prefix="proposa-validate-") as temp_dir:
+            copy = Path(temp_dir) / ("upload" + suffix)
+            metadata = _save_local_path(source_path, copy, role)
+            if suffix == ".pdf":
+                _validate_text_pdf(copy, role)
+            else:
+                _validate_docx(copy, role)
+        return {
+            "valid": True, "file_type": suffix[1:],
+            "is_text_pdf": True if suffix == ".pdf" else None,
+            "file_role": role, "resolved_path": str(source_path),
+            **metadata, "message": "文件检测通过",
+        }
+    except HTTPException as exc:
+        return _validation_failure(exc.status_code, str(exc.detail), paths)
+    except (OSError, ValueError):
+        return _validation_failure(400, "文件路径无效或文件无法读取", paths)
+
+
+@app.post(
+    "/api/v1/files/validate",
+    summary="上传阶段检测 DOCX 或文字型 PDF",
+    responses={
+        200: {"description": "检测通过,返回 valid/file_type/is_text_pdf/filename/size/sha256/message"},
+        400: {"description": "空文件、不支持的后缀、损坏或加密文件、无文字 PDF"},
+        413: {"description": "文件超过 PROPOSA_API_MAX_UPLOAD_MB"},
+        415: {"description": "仅支持 multipart/form-data 或 application/json"},
+        422: {"description": "文件或 JSON 参数缺失/类型错误"},
+    },
+    openapi_extra={"requestBody": {"required": True, "content": {
+        "multipart/form-data": {"schema": {"type": "object", "required": ["file"],
+            "properties": {"file": {"type": "string", "format": "binary"}}}},
+        "application/json": {"schema": {"type": "object",
+            "required": ["file_path", "file_role", "is_absolute"],
+            "properties": {
+                "file_path": {"type": "string"},
+                "file_role": {"type": "string", "enum": list(_FILE_ROLES)},
+                "is_absolute": {"type": "boolean"}
+            }}}
+    }}},
+)
+async def validate_file(request: Request):
+    """仅检测,不创建生成任务。PDF 至少一页有文字;不执行 OCR。"""
+    try:
+        content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
+        if content_type == "application/json":
+            try:
+                payload = await request.json()
+            except ValueError:
+                return _validation_failure(400, "请求体不是有效的 JSON")
+            return await run_in_threadpool(_inspect_local_file, payload)
+        if content_type != "multipart/form-data":
+            raise HTTPException(415, "Content-Type 必须是 multipart/form-data 或 application/json")
+        return await _validate_uploaded_file(request)
+    except StarletteHTTPException as exc:
+        return _validation_failure(exc.status_code, str(exc.detail))
+
+
+async def _validate_uploaded_file(request: Request) -> dict:
+    async with request.form() as form:
+        request.state.audit_form = [
+            {"name": name, "value": {"filename": value.filename,
+             "size": value.size, "content_type": value.content_type}
+             if hasattr(value, "filename") else value}
+            for name, value in form.multi_items()
+        ]
+        upload = _required_upload(form, "file")
+        if len(form.getlist("file")) != 1 or sum(
+            hasattr(value, "file") for _, value in form.multi_items()
+        ) != 1:
+            raise HTTPException(status_code=422, detail="必须且只能上传一个 file 文件")
+        return await run_in_threadpool(_inspect_upload, upload)
+
+
 @app.get("/api/v1/jobs/{request_id}")
 def get_job(request_id: str) -> dict:
     job = _resolve_job(request_id)