"""可替换的 PDF 查询/下载接口;模拟接口返回原始测试 PDF,保留其真实身份。""" import hashlib from pathlib import Path import httpx from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse def create_mock_report_app(pdf_path): source = Path(pdf_path).resolve() if not source.is_file() or source.suffix.lower() != '.pdf': raise ValueError('模拟报告接口需要已有的测试 PDF 文件') sha = hashlib.sha256(source.read_bytes()).hexdigest() app = FastAPI(title='主通风机测定报告模拟接口') @app.get('/api/main-fan/reports') async def reports(mine_code: str, shaft_id: str): return {'code': 200, 'data': [{ 'file_id': 'fixture-' + sha[:16], 'filename': source.name, 'sha256': sha, 'download_path': '/api/main-fan/report-files/' + sha + '.pdf', 'requested_mine_code': mine_code, 'requested_shaft_id': shaft_id, 'is_mock': True, 'binding': 'test_fixture_not_mine_report', 'note': '复用既有测试PDF原件;不改写封面、矿名或设备身份,不能视为目标矿的正式报告。'}]} @app.get('/api/main-fan/report-files/{name}') async def download(name: str): if name != sha + '.pdf': raise HTTPException(404, '报告文件不存在') return FileResponse(source, media_type='application/pdf', filename=source.name) return app class ReportClient: def __init__(self, http): self.http = http async def list(self, mine_code, shaft_id): params = {'mine_code': mine_code, 'shaft_id': shaft_id} response = await self.http.get('/api/main-fan/reports', params=params) response.raise_for_status() body = response.json() if str(body.get('code')) not in {'0', '200'} or not isinstance(body.get('data'), list): raise ValueError('报告列表接口没有返回成功的文件数组') return body['data'], {'method': 'GET', 'path': '/api/main-fan/reports', 'params': params}, body async def download(self, record, target): path = record.get('download_path', '') if not path.startswith('/api/main-fan/report-files/') or '..' in path: raise ValueError('报告下载路径不在已配置的文件接口内') partial = target.with_suffix('.part') target.parent.mkdir(parents=True, exist_ok=True) size, digest = 0, hashlib.sha256() try: async with self.http.stream('GET', path) as response: response.raise_for_status() if 'pdf' not in response.headers.get('content-type', '').lower(): raise ValueError('报告下载未返回 PDF') with partial.open('wb') as stream: async for chunk in response.aiter_bytes(): size += len(chunk) if size > 50 * 1024 * 1024: raise ValueError('报告文件超过50MB') digest.update(chunk) stream.write(chunk) if not size or not partial.read_bytes()[:5] == b'%PDF-': raise ValueError('报告文件为空或不是PDF原件') sha = digest.hexdigest() if record.get('sha256') and sha != record['sha256']: raise ValueError('报告实际内容hash与接口声明不一致') partial.replace(target) return sha finally: partial.unlink(missing_ok=True) def mock_report_client(pdf_path): return httpx.AsyncClient(transport=httpx.ASGITransport(app=create_mock_report_app(pdf_path)), base_url='http://mock-report.local', timeout=30)