| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- """JSON 走网关,文件用平台凭证直连;与真网关相同的内部接口契约。"""
- import asyncio
- import copy
- from dataclasses import dataclass, field
- import hashlib
- import json
- from pathlib import Path
- import uuid
- from fastapi import FastAPI, Header, HTTPException, Query, Request, UploadFile, File
- from fastapi.responses import FileResponse, StreamingResponse
- ROOT = Path(__file__).resolve().parents[1]
- INTERNAL_TOKEN = "mock-internal-token"
- PLATFORM_TOKEN = "mock-platform-file-token"
- MANAGE_TOKEN = "mock-platform-manage-token"
- CLIENT_ID = "mock-file-client"
- def load_sample():
- return json.loads((ROOT / "examples/main_fan/reviewed_sample.json").read_text(encoding="utf-8"))
- def task_input(scenario="original"):
- if scenario == "original":
- return {"mine_code": "mock-ci", "mine_name": "神木市瓷窑塔矿业有限公司",
- "fan_id": "00000541", "fan_name": "地面主通一号", "use_gateway": True,
- "evaluation_time": "2026-10-14T12:00:00+08:00"}
- if scenario == "report":
- return {"mine_code": "mock-zy", "mine_name": "陕西众源煤业集团有限公司",
- "fan_id": "mock-report-fan-1", "fan_name": "1号通风机", "use_gateway": True,
- "evaluation_time": "2026-10-14T12:00:00+08:00"}
- if scenario != "synthetic":
- raise ValueError("场景只能为 original、report 或 synthetic")
- # 额外计算分支验证;以下是合成数据,不宣称陕西局提供了这些量。
- base = {"mine_code": "mock-synthetic", "mine_name": "合成测试煤矿",
- "fan_id": "synthetic-fan-1", "fan_name": "1号风机",
- "evaluation_time": "2026-10-14T12:00:00+08:00", "points": [], "confirmed_bindings": []}
- for code, name, metric, value, unit in [("synthetic-q", "1号风机风量", "flow", 60, "m3/s"),
- ("synthetic-p", "1号风机静压增量", "static_pressure", 500, "Pa"),
- ("synthetic-w", "1号风机轴功率", "shaft_power", 50, "kW")]:
- base["points"].append({"point_code": code, "name": name, "mine_code": base["mine_code"],
- "mine_name": base["mine_name"], "metric": metric, "value": value, "unit": unit,
- "status": "normal", "observed_at": "2026-10-14T12:00:00+08:00",
- "evidence": {"source": "synthetic-test", "method": "synthetic", "excerpt": "合成测试数值"}})
- base["confirmed_bindings"].append({"point_code": code, "fan_id": base["fan_id"],
- "confirmed_by": "合成测试", "reason": "仅模拟已确认现场拓扑"})
- return base
- @dataclass
- class MockState:
- data_dir: Path
- sample: dict = field(default_factory=load_sample)
- registered: dict = field(default_factory=dict)
- enabled: dict = field(default_factory=dict)
- queues: dict = field(default_factory=dict)
- receipts: dict = field(default_factory=dict)
- uploads: dict = field(default_factory=dict)
- calls: list = field(default_factory=list)
- raw_files: bool = False
- operating_data: dict = field(default_factory=dict)
- data_agents: set[str] = field(default_factory=lambda: {"mock-main-fan"})
- @classmethod
- def from_raw_files(cls, data_dir, *, operating_data=None):
- """网页实测只建立原件索引,不加载人工转录的离线夹具。"""
- data_dir = Path(data_dir).resolve()
- assets = {}
- for index, source in enumerate(sorted(data_dir.rglob('*'))):
- if not source.is_file() or source.suffix.lower() not in {".pdf", ".png", ".jpg", ".jpeg", ".webp"}:
- continue
- if not source.resolve().is_relative_to(data_dir) or source.is_symlink():
- continue
- digest = hashlib.sha256(source.read_bytes()).hexdigest()
- key = "performance_pdf" if source.name == "通风机性能测定报告.pdf" else f"source-{digest[:16]}-{index}"
- assets[key] = {"relative_path": source.relative_to(data_dir).as_posix(), "sha256": digest}
- if not assets:
- raise ValueError("测试目录没有 PDF 或图片原件")
- return cls(data_dir, sample={"assets": assets, "devices": [], "points": []}, raw_files=True,
- operating_data=operating_data or {})
- def asset(self, key):
- expected = self.sample["assets"][key]
- source = (self.data_dir / expected["relative_path"]).resolve()
- if not source.is_relative_to(self.data_dir.resolve()) or not source.is_file():
- raise ValueError("测试样例文件缺失或超出指定目录")
- if hashlib.sha256(source.read_bytes()).hexdigest() != expected["sha256"]:
- if self.raw_files:
- raise ValueError("原始测试文件发生变化,请重启模拟网关刷新文件索引")
- raise ValueError("测试样例内容已变化,请重新核验转录,禁止命中旧缓存")
- return source
- def file_index(self):
- if self.raw_files:
- return [{"file_id": key, "version": value["sha256"][:16], "filename": self.asset(key).name,
- "path": f"/mock-platform/files/{key}", "params": {}}
- for key, value in self.sample["assets"].items()]
- self.asset("performance_pdf")
- return [{"file_id": "performance_pdf", "version": self.sample["assets"]["performance_pdf"]["sha256"][:16],
- "filename": "通风机性能测定报告.pdf", "path": "/mock-platform/files/performance_pdf", "params": {}}]
- def seed_reviewed_cache(self):
- """显式离线演示才调用,生产不自动加载样例缓存。"""
- from db import main_fan_store
- from algorithms.main_fan.models import DocumentFacts
- self.asset("performance_pdf")
- facts = DocumentFacts.model_validate(self.sample["document_facts"])
- main_fan_store.save_document(self.sample["assets"]["performance_pdf"]["sha256"],
- facts.model_dump(mode="json"), "manual_transcription")
- def create_app(state: MockState):
- app = FastAPI(title="陕西局网关本地模拟(测试数据)")
- app.state.mock = state
- def internal(authorization, agent_code, *, active=True, data_access=False):
- if authorization != f"Bearer {INTERNAL_TOKEN}":
- raise HTTPException(401, "模拟网关凭证无效")
- if not agent_code:
- raise HTTPException(400, "缺少 X-Agent-Code")
- preauthorized = data_access and agent_code in state.data_agents
- if active and agent_code not in state.registered and not preauthorized:
- raise HTTPException(404, "未注册")
- if active and not state.enabled.get(agent_code, preauthorized):
- raise HTTPException(403, "智能体已停止")
- def success(data):
- return {"code": 200, "success": True, "data": data}
- @app.post("/internal/agents/register")
- async def register(payload: dict, authorization: str = Header(""), x_agent_code: str = Header("")):
- internal(authorization, x_agent_code, active=False)
- if payload.get("agentCode") != x_agent_code or not payload.get("checkTypeCode"):
- raise HTTPException(422, "注册身份不匹配或缺少研判类型")
- state.registered[x_agent_code] = payload
- state.enabled.setdefault(x_agent_code, True)
- state.queues.setdefault(x_agent_code, asyncio.Queue())
- state.calls.append({"operation": "register", "agent": x_agent_code})
- return success(payload)
- @app.get("/internal/monitor/token")
- async def token(authorization: str = Header(""), x_agent_code: str = Header("")):
- internal(authorization, x_agent_code, data_access=True)
- state.calls.append({"operation": "token", "agent": x_agent_code})
- return success({"access_token": PLATFORM_TOKEN, "client_id": CLIENT_ID})
- @app.post("/internal/monitor/proxy")
- async def proxy(payload: dict, authorization: str = Header(""), x_agent_code: str = Header("")):
- internal(authorization, x_agent_code, data_access=True)
- path = payload.get("path")
- state.calls.append({"operation": "proxy", "agent": x_agent_code, "path": path})
- mine = (payload.get("params") or {}).get("mineCode")
- if payload.get("method") != "GET":
- return success({"status_code": 405, "body": {"code": 405}})
- kind = {"/mock-platform/main-fan/devices": "devices", "/mock-platform/main-fan/points": "points",
- "/mock-platform/main-fan/reports": "files", "/mock-platform/main-fan/series":"operating_series",
- "/mock-platform/main-fan/states":"fan_state_records", "/mock-platform/main-fan/switches":"switch_records"}.get(path)
- if kind is None:
- return success({"status_code": 404, "body": {"code": 404}})
- rows = state.file_index() if kind == "files" else [r for r in
- [*state.sample.get(kind,[]),*state.operating_data.get(kind,[])] if not mine or r["mine_code"] == mine]
- return success({"status_code": 200, "body": {"code": 200, "data": {"items": copy.deepcopy(rows)}}})
- @app.get('/mock/main-fan/curves/{kind}')
- async def operating_curve(kind: str):
- raw = state.operating_data.get('raw_curves',{}).get(kind)
- if raw is None:
- raise HTTPException(404,'未配置该模拟曲线')
- return copy.deepcopy(raw)
- @app.get("/mock-platform/files/{asset_key}")
- async def download(asset_key: str, request: Request, authorization: str = Header(""), clientid: str = Header("")):
- # 直连文件平台只接收下发的平台鉴权,而不是网关内部凭证。
- if authorization != f"Bearer {PLATFORM_TOKEN}" or clientid != CLIENT_ID:
- raise HTTPException(401, "文件系统凭证无效")
- if asset_key not in state.sample["assets"]:
- raise HTTPException(404, "文件不存在")
- state.calls.append({"operation": "direct_file", "asset": asset_key,
- "gateway_identity_header_present": bool(request.headers.get("X-Agent-Code"))})
- source = state.asset(asset_key)
- return FileResponse(source, filename=source.name)
- @app.post("/internal/monitor/upload")
- async def upload(file: UploadFile = File(...), path: str = Query("mock-evidence"),
- authorization: str = Header(""), x_agent_code: str = Header("")):
- internal(authorization, x_agent_code)
- content = await file.read(50 * 1024 * 1024 + 1)
- if len(content) > 50 * 1024 * 1024:
- raise HTTPException(413, "测试文件过大")
- stored_path = "mock-evidence/" + hashlib.sha256(content).hexdigest() + "/" + Path(file.filename or "file").name
- state.uploads[stored_path] = {"sha256": hashlib.sha256(content).hexdigest(), "size": len(content)}
- state.calls.append({"operation": "upload", "agent": x_agent_code})
- return success({"data": {"status": "successed", "path": stored_path}})
- @app.post("/internal/push/ai_platform")
- async def push(payload: dict, authorization: str = Header(""), x_agent_code: str = Header("")):
- internal(authorization, x_agent_code)
- if payload.get("agentCode") != x_agent_code or len(payload.get("taskId", "")) > 32:
- raise HTTPException(422, "推送身份/任务标识错误")
- if any(not payload.get(k) for k in ("taskId", "mineCode", "mineName", "result", "checkTypeCode")):
- raise HTTPException(422, "推送缺少公共字段")
- key = (x_agent_code, payload["taskId"])
- prior = state.receipts.get(key)
- if prior and prior["payload"] != payload:
- raise HTTPException(409, "同一任务载荷变化")
- if not prior:
- receipt = {"checkId": "mock-check-" + uuid.uuid4().hex,
- "clueIds": ["mock-clue-" + uuid.uuid4().hex for _ in payload.get("clues", [])]}
- state.receipts[key] = {"payload": payload, "receipt": receipt}
- state.calls.append({"operation": "push", "agent": x_agent_code, "task_id": payload["taskId"]})
- return success(state.receipts[key]["receipt"])
- @app.get("/internal/events/stream")
- async def events(request: Request, agentCode: str, authorization: str = Header(""), x_agent_code: str = Header("")):
- internal(authorization, x_agent_code)
- if agentCode != x_agent_code:
- raise HTTPException(403, "订阅身份不匹配")
- async def stream():
- yield ": connected\n\n"
- while state.enabled[agentCode] and not await request.is_disconnected():
- try:
- item = await asyncio.wait_for(state.queues[agentCode].get(), .25)
- yield "event: main_fan.judgment\nid: " + item["id"] + "\ndata: " + json.dumps({"data": item["data"]}, ensure_ascii=False) + "\n\n"
- except asyncio.TimeoutError:
- yield ": heartbeat\n\n"
- return StreamingResponse(stream(), media_type="text/event-stream")
- @app.post("/mock/tasks")
- async def publish_task(payload: dict, authorization: str = Header(""), x_agent_code: str = Header("")):
- internal(authorization, x_agent_code)
- scenario = payload.get("scenario", "original")
- task_id = payload.get("taskId", "mock-" + uuid.uuid4().hex[:20])
- data = {"taskId": task_id, "input": task_input(scenario), "files": []}
- await state.queues[x_agent_code].put({"id": task_id, "data": data})
- return success({"taskId": task_id})
- @app.get("/openapi/agent/manage")
- async def manage(agentCode: str, action: str, token: str):
- if token != MANAGE_TOKEN:
- raise HTTPException(401, "模拟管理凭证无效")
- if agentCode not in state.registered:
- raise HTTPException(404, "未注册")
- if action in {"start", "restart"}:
- state.enabled[agentCode] = True
- elif action == "stop":
- state.enabled[agentCode] = False
- elif action not in {"status", "logs"}:
- raise HTTPException(422, "管理动作无效")
- data = {"agentCode": agentCode, "onlineStatus": "online" if state.enabled[agentCode] else "offline",
- "judgmentCount": sum(code == agentCode for code, _ in state.receipts)}
- if action == "logs":
- data["rows"] = [r for r in state.calls if r.get("agent") == agentCode]
- return {"code": 200, "message": "模拟管理操作成功", "data": data}
- return app
|