business_routes.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. """业务目录及持久化任务 API,管理和提交使用单独的服务凭证。"""
  2. import asyncio
  3. import hmac
  4. import json
  5. from typing import Any, Literal
  6. from fastapi import APIRouter, Depends, Header, HTTPException, Query
  7. from pydantic import BaseModel, ConfigDict, Field, ValidationError
  8. import config
  9. from db import business_store
  10. from extensions.registry import load_extensions
  11. from runtime import business
  12. from runtime.business_models import RemoteFile
  13. from runtime.business_tasks import get_controller, public_job, submit
  14. from runtime.result_models import PlatformResultPayload
  15. router = APIRouter(prefix="/business-agents", tags=["业务智能体"])
  16. def spec_of(name):
  17. try:
  18. return load_extensions().business(name)
  19. except ValueError as exc:
  20. raise HTTPException(404, str(exc)) from None
  21. async def service_auth(authorization: str = Header(default="")):
  22. token = config.get_str("BUSINESS_API_TOKEN")
  23. if not token:
  24. raise HTTPException(503, "业务任务 API 未配置 BUSINESS_API_TOKEN")
  25. if not hmac.compare_digest(authorization.encode(), f"Bearer {token}".encode()):
  26. raise HTTPException(401, "业务任务 API 认证失败")
  27. class JobRequest(BaseModel):
  28. model_config = ConfigDict(extra="forbid")
  29. input: dict[str, Any]
  30. user_name: str = Field(default="default", min_length=1, max_length=128)
  31. request_id: str | None = Field(default=None, min_length=1, max_length=128)
  32. files: list[RemoteFile] = Field(default_factory=list, max_length=20)
  33. publish: bool = False
  34. @router.get("")
  35. async def list_agents():
  36. return {"items": [{"name": s.name, "display_name": s.display_name,
  37. "description": load_extensions().agents[s.name].description,
  38. "max_concurrency": s.max_concurrency,
  39. "gateway_configured": bool(s.gateway.agent_code),
  40. "result_platform_configured": bool(s.result_binding.agent_code) and config.get_bool("RESULT_PLATFORM_ENABLED", False)}
  41. for s in load_extensions().business_agents.values()]}
  42. @router.get("/{name}/schema")
  43. async def schemas(name: str):
  44. spec = spec_of(name)
  45. return {"input": spec.input_schema.model_json_schema(), "result": spec.result_schema.model_json_schema(),
  46. "platform_result": PlatformResultPayload.model_json_schema()}
  47. @router.post("/main_fan/mine-catalog", status_code=201, dependencies=[Depends(service_auth)])
  48. async def refresh_main_fan_mines():
  49. """固定首步:读取部门树,冻结仅含正常生产煤矿的目录。"""
  50. from extensions.main_fan import mine_catalog
  51. from extensions.shaanxi.client import GatewayError
  52. spec_of('main_fan')
  53. try:
  54. return await mine_catalog.refresh()
  55. except (GatewayError, mine_catalog.MineCatalogError) as exc:
  56. raise HTTPException(502, str(exc)) from None
  57. except RuntimeError as exc:
  58. raise HTTPException(503, str(exc)) from None
  59. @router.get("/main_fan/mine-catalog", dependencies=[Depends(service_auth)])
  60. async def latest_main_fan_mines():
  61. """查看最近一次成功筛选的煤矿清单。"""
  62. return await _main_fan_mines()
  63. @router.get("/main_fan/mine-catalog/{snapshot_id}", dependencies=[Depends(service_auth)])
  64. async def main_fan_mines(snapshot_id: str):
  65. """按快照编号重读本批次固定输入。"""
  66. return await _main_fan_mines(snapshot_id)
  67. async def _main_fan_mines(snapshot_id=None):
  68. from extensions.main_fan import mine_catalog
  69. spec_of('main_fan')
  70. try:
  71. return await asyncio.to_thread(mine_catalog.load, snapshot_id)
  72. except (FileNotFoundError, ValueError, KeyError):
  73. raise HTTPException(404, '煤矿清单不存在,请先刷新目录') from None
  74. from extensions.main_fan.batch_models import BatchRequest
  75. @router.post('/main_fan/batches', status_code=202, dependencies=[Depends(service_auth)])
  76. async def create_main_fan_batch(request: BatchRequest):
  77. """显式选矿的结果批次;始终落本地,按总开关选择是否进入推送队列。"""
  78. from extensions import get_service
  79. try:
  80. return get_service('main_fan_batches').submit(request)
  81. except RuntimeError as exc:
  82. raise HTTPException(503, '本地主通风机批次尚未启用') from exc
  83. except ValueError as exc:
  84. raise HTTPException(422, str(exc)) from None
  85. @router.get('/main_fan/batches/{batch_id}', dependencies=[Depends(service_auth)])
  86. async def get_main_fan_batch(batch_id: str):
  87. from extensions import get_service
  88. try:
  89. return get_service('main_fan_batches').get(batch_id)
  90. except RuntimeError as exc:
  91. raise HTTPException(503, '本地主通风机批次尚未启用') from exc
  92. except (ValueError, FileNotFoundError):
  93. raise HTTPException(404, '批次不存在') from None
  94. @router.get("/{name}/status", dependencies=[Depends(service_auth)])
  95. async def status(name: str):
  96. spec_of(name)
  97. state, metrics = await asyncio.gather(asyncio.to_thread(business_store.state, name),
  98. asyncio.to_thread(business_store.metrics, name))
  99. return {"name": name, **state, **metrics}
  100. @router.post("/{name}/manage", dependencies=[Depends(service_auth)])
  101. async def manage(name: str, action: Literal["start", "stop", "restart"]):
  102. spec_of(name)
  103. if action in {"stop", "restart"}:
  104. await business.stop(name)
  105. if action in {"start", "restart"}:
  106. await business.start(name)
  107. return await status(name)
  108. @router.post("/{name}/jobs", status_code=202, dependencies=[Depends(service_auth)])
  109. async def create_job(name: str, payload: JobRequest):
  110. spec_of(name)
  111. try:
  112. get_controller()
  113. job = await asyncio.to_thread(submit, name, payload.input, user_name=payload.user_name,
  114. files=[f.model_dump() for f in payload.files], request_id=payload.request_id, publish=payload.publish)
  115. except business.BusinessStopped as exc:
  116. raise HTTPException(409, str(exc)) from None
  117. except ValidationError as exc:
  118. raise HTTPException(422, exc.errors(include_input=False, include_context=False)) from None
  119. except ValueError as exc:
  120. raise HTTPException(422, str(exc)) from None
  121. except RuntimeError as exc:
  122. raise HTTPException(503, str(exc)) from None
  123. return public_job(job)
  124. @router.get("/{name}/jobs", dependencies=[Depends(service_auth)])
  125. async def jobs(name: str, user_name: str = "default", limit: int = Query(50, ge=1, le=200)):
  126. spec_of(name)
  127. return {"items": [public_job(j) for j in await asyncio.to_thread(business_store.list_jobs, name, user_name, limit)]}
  128. async def owned_job(name, job_id, user_name):
  129. spec_of(name)
  130. job = await asyncio.to_thread(business_store.get, job_id)
  131. if not job or job["agent_name"] != name or job["user_name"] != user_name:
  132. raise HTTPException(404, "业务任务不存在")
  133. return job
  134. @router.get("/{name}/jobs/{job_id}", dependencies=[Depends(service_auth)])
  135. async def detail(name: str, job_id: str, user_name: str = "default"):
  136. return public_job(await owned_job(name, job_id, user_name))
  137. @router.get("/{name}/jobs/{job_id}/result", dependencies=[Depends(service_auth)])
  138. async def exported_result(name: str, job_id: str, user_name: str = "default"):
  139. job = await owned_job(name, job_id, user_name)
  140. result = json.loads(job["result_json"]) if job.get("result_json") else {}
  141. if not result.get("platform_payload"):
  142. raise HTTPException(409, "当前任务还没有新格式的结果报文")
  143. return PlatformResultPayload.model_validate(result["platform_payload"]).model_dump(mode="json")
  144. @router.post("/{name}/jobs/{job_id}/publish", status_code=202, dependencies=[Depends(service_auth)])
  145. async def publish(name: str, job_id: str, user_name: str = "default"):
  146. job = await owned_job(name, job_id, user_name)
  147. try:
  148. await get_controller().queue_publish(job)
  149. except ValueError as exc:
  150. raise HTTPException(409, str(exc)) from None
  151. except RuntimeError as exc:
  152. raise HTTPException(503, str(exc)) from None
  153. return public_job(await asyncio.to_thread(business_store.get, job_id))
  154. @router.get("/main_fan/analyses/{analysis_id}/result", dependencies=[Depends(service_auth)])
  155. async def exported_analysis(analysis_id: str, session_id: str, user_name: str = "default"):
  156. from db import main_fan_store
  157. try:
  158. result = await asyncio.to_thread(main_fan_store.canonical_result,
  159. {"analysis_id": analysis_id}, user_name, session_id)
  160. except ValueError:
  161. raise HTTPException(404, "研判结果不存在或不属于当前会话") from None
  162. if not result.get("platform_payload"):
  163. raise HTTPException(409, "历史结果没有新格式报文,请重新执行研判")
  164. return PlatformResultPayload.model_validate(result["platform_payload"]).model_dump(mode="json")
  165. @router.post("/{name}/jobs/{job_id}/retry", dependencies=[Depends(service_auth)])
  166. async def retry(name: str, job_id: str, user_name: str = "default"):
  167. job = await owned_job(name, job_id, user_name)
  168. if job["status"] == "completed" and job["push_status"] == "failed":
  169. return await publish(name, job_id, user_name)
  170. elif not await asyncio.to_thread(business_store.retry, job_id):
  171. raise HTTPException(409, "任务当前状态不支持重试")
  172. return await detail(name, job_id, user_name)