routes.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  1. """Governed control-plane endpoints for AI-authored data rules."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. from datetime import UTC, datetime, timedelta
  6. from typing import Any
  7. from flask import current_app, g, jsonify, request
  8. from app import db
  9. from app.api.data_rules import bp
  10. from app.core.data_rules.authoring import (
  11. OpenAICompatibleRuleModel,
  12. RuleAuthoringAgent,
  13. )
  14. from app.core.data_rules.contracts import (
  15. dataflow_spec_hash,
  16. rule_spec_hash,
  17. standard_spec_hash,
  18. validate_dataflow_spec,
  19. validate_rule_spec,
  20. validate_standard_spec,
  21. )
  22. from app.core.data_rules.deployment import DataFlowDeploymentService
  23. from app.core.data_rules.production_line import resolve_production_line
  24. from app.core.data_rules.publication import (
  25. GenerationReceiptSigner,
  26. LogicalRuleCompiler,
  27. PhysicalPlanPublicationService,
  28. RulePublicationService,
  29. RuleValidationRejected,
  30. ServerOwnedLogicalDryRunRunner,
  31. ServerOwnedPhysicalPreflightRunner,
  32. generation_receipt_claims,
  33. )
  34. from app.core.data_rules.release import ProductionLineReleaseService
  35. from app.core.data_rules.repository import DataRuleRepository
  36. from app.core.data_rules.schema_resolver import (
  37. Neo4jSchemaMetadataCatalog,
  38. SchemaResolver,
  39. )
  40. from app.core.orchestration.engines.kestra import KestraAdapter
  41. from app.models.result import failed, success
  42. from app.runner.auth import TaskTokenIssuer
  43. _VALIDATORS = {
  44. "rule": (validate_rule_spec, rule_spec_hash),
  45. "standard": (validate_standard_spec, standard_spec_hash),
  46. "dataflow": (validate_dataflow_spec, dataflow_spec_hash),
  47. }
  48. def _body() -> dict[str, Any]:
  49. value = request.get_json(silent=True)
  50. if not isinstance(value, dict):
  51. raise ValueError("request body must be an object")
  52. return value
  53. def _bad_request(message: str = "规则请求无效"):
  54. return jsonify(failed(message, code=400)), 400
  55. def _closed_body(allowed: set[str]) -> dict[str, Any]:
  56. body = _body()
  57. if set(body) - allowed:
  58. raise ValueError("request contains unsupported fields")
  59. return body
  60. def _repository() -> DataRuleRepository:
  61. configured = current_app.extensions.get("data_rule_repository")
  62. if configured is not None:
  63. return configured
  64. return DataRuleRepository(db.session)
  65. def _release_service() -> ProductionLineReleaseService:
  66. configured = current_app.extensions.get("production_line_release_service")
  67. if configured is not None:
  68. return configured
  69. repository = _repository()
  70. resolver = current_app.extensions.get("data_rule_schema_resolver")
  71. if resolver is None:
  72. catalog = current_app.extensions.get("data_rule_metadata_catalog")
  73. resolver = SchemaResolver(
  74. catalog or Neo4jSchemaMetadataCatalog(), repository
  75. )
  76. return ProductionLineReleaseService(repository, schema_resolver=resolver)
  77. def _schema_resolver() -> SchemaResolver:
  78. configured = current_app.extensions.get("data_rule_schema_resolver")
  79. if configured is not None:
  80. return configured
  81. repository = _repository()
  82. catalog = current_app.extensions.get("data_rule_metadata_catalog")
  83. return SchemaResolver(catalog or Neo4jSchemaMetadataCatalog(), repository)
  84. def _deployment_readiness() -> dict[str, Any]:
  85. reasons = []
  86. if not current_app.config.get("KESTRA_BASE_URL"):
  87. reasons.append("kestra_base_url_missing")
  88. if not current_app.config.get("KESTRA_USERNAME"):
  89. reasons.append("kestra_username_missing")
  90. if not current_app.config.get("KESTRA_PASSWORD"):
  91. reasons.append("kestra_password_missing")
  92. secret = current_app.config.get("RUNNER_TASK_TOKEN_SECRET")
  93. if not isinstance(secret, str) or len(secret.encode("utf-8")) < 32:
  94. reasons.append("runner_task_token_secret_invalid")
  95. gate_open = bool(
  96. current_app.config.get("DATA_FACTORY_ACTIVATION_ENABLED", False)
  97. )
  98. if not gate_open:
  99. reasons.append("post_acceptance_activation_gate_closed")
  100. return {
  101. "ready": not reasons,
  102. "gate_open": gate_open,
  103. "reasons": reasons,
  104. "server_owned": True,
  105. }
  106. def _deployment_service() -> DataFlowDeploymentService:
  107. configured = current_app.extensions.get("dataflow_deployment_service")
  108. if configured is not None:
  109. return configured
  110. readiness = _deployment_readiness()
  111. infrastructure_reasons = [
  112. reason
  113. for reason in readiness["reasons"]
  114. if reason != "post_acceptance_activation_gate_closed"
  115. ]
  116. if infrastructure_reasons:
  117. raise RuntimeError("data factory deployment infrastructure is not ready")
  118. engine = KestraAdapter(
  119. current_app.config["KESTRA_BASE_URL"],
  120. current_app.config["KESTRA_USERNAME"],
  121. current_app.config["KESTRA_PASSWORD"],
  122. tenant_id=current_app.config.get("KESTRA_TENANT_ID", "main"),
  123. timeout=int(
  124. current_app.config.get("KESTRA_HTTP_TIMEOUT_SECONDS", 30)
  125. ),
  126. )
  127. service = DataFlowDeploymentService(
  128. _repository(),
  129. engine=engine,
  130. token_issuer=TaskTokenIssuer(
  131. current_app.config["RUNNER_TASK_TOKEN_SECRET"]
  132. ),
  133. )
  134. current_app.extensions["dataflow_deployment_service"] = service
  135. return service
  136. def _activation_gate_ready() -> bool:
  137. if current_app.extensions.get("dataflow_deployment_service") is not None:
  138. return bool(
  139. current_app.config.get("DATA_FACTORY_ACTIVATION_ENABLED", False)
  140. )
  141. return bool(_deployment_readiness()["ready"])
  142. def _receipt_signer() -> GenerationReceiptSigner:
  143. configured = current_app.extensions.get("generation_receipt_signer")
  144. if configured is not None:
  145. return configured
  146. secret = current_app.config.get("RULE_GENERATION_RECEIPT_SECRET")
  147. if not isinstance(secret, str) or not secret.strip():
  148. raise RuntimeError(
  149. "dedicated generation receipt secret is not configured"
  150. )
  151. signer = GenerationReceiptSigner(secret)
  152. current_app.extensions["generation_receipt_signer"] = signer
  153. return signer
  154. def _rule_artifact_store():
  155. configured = current_app.extensions.get("rule_artifact_store")
  156. if configured is not None:
  157. return configured
  158. from minio import Minio
  159. from app.runner.artifacts import ArtifactStore
  160. store = ArtifactStore(
  161. Minio(
  162. current_app.config["MINIO_HOST"],
  163. access_key=current_app.config["MINIO_USER"],
  164. secret_key=current_app.config["MINIO_PASSWORD"],
  165. secure=bool(current_app.config["MINIO_SECURE"]),
  166. ),
  167. bucket=current_app.config["MINIO_BUCKET"],
  168. max_artifact_bytes=32 * 1024 * 1024,
  169. max_rows=100_000,
  170. memory_limit_bytes=256 * 1024 * 1024,
  171. max_ttl_seconds=3600,
  172. )
  173. current_app.extensions["rule_artifact_store"] = store
  174. return store
  175. def _publication_service() -> RulePublicationService:
  176. configured = current_app.extensions.get("rule_publication_service")
  177. if configured is not None:
  178. return configured
  179. test_runner = current_app.extensions.get("rule_validation_test_runner")
  180. if test_runner is None:
  181. try:
  182. test_runner = ServerOwnedLogicalDryRunRunner(
  183. _rule_artifact_store()
  184. )
  185. except Exception as exc:
  186. raise RuntimeError(
  187. "trusted rule validation test runner is not configured"
  188. ) from exc
  189. current_app.extensions["rule_validation_test_runner"] = test_runner
  190. service = RulePublicationService(
  191. _repository(),
  192. receipt_signer=_receipt_signer(),
  193. compiler=LogicalRuleCompiler(),
  194. test_runner=test_runner,
  195. )
  196. current_app.extensions["rule_publication_service"] = service
  197. return service
  198. def _physical_publication_service() -> PhysicalPlanPublicationService:
  199. configured = current_app.extensions.get(
  200. "physical_plan_publication_service"
  201. )
  202. if configured is not None:
  203. return configured
  204. test_runner = current_app.extensions.get("rule_physical_test_runner")
  205. if test_runner is None:
  206. try:
  207. from app.core.data_source.runtime import get_data_source_manager
  208. test_runner = ServerOwnedPhysicalPreflightRunner(
  209. _rule_artifact_store(),
  210. datasource_manager=get_data_source_manager(),
  211. )
  212. except Exception as exc:
  213. raise RuntimeError(
  214. "trusted physical plan test runner is not configured"
  215. ) from exc
  216. current_app.extensions["rule_physical_test_runner"] = test_runner
  217. service = PhysicalPlanPublicationService(
  218. _repository(), test_runner=test_runner
  219. )
  220. current_app.extensions["physical_plan_publication_service"] = service
  221. return service
  222. def _metadata_hash(value: Any) -> str:
  223. return hashlib.sha256(
  224. json.dumps(
  225. value,
  226. sort_keys=True,
  227. separators=(",", ":"),
  228. ensure_ascii=False,
  229. ).encode("utf-8")
  230. ).hexdigest()
  231. @bp.get("/capabilities")
  232. def capabilities():
  233. readiness = _deployment_readiness()
  234. if current_app.extensions.get("dataflow_deployment_service") is not None:
  235. readiness = {
  236. **readiness,
  237. "ready": bool(readiness["gate_open"]),
  238. "reasons": [
  239. reason
  240. for reason in readiness["reasons"]
  241. if reason
  242. == "post_acceptance_activation_gate_closed"
  243. ],
  244. }
  245. return jsonify(
  246. success(
  247. {
  248. "natural_language_authoring": True,
  249. "schema_constrained_candidates": True,
  250. "production_line_preview": True,
  251. "immutable_asset_versions": True,
  252. "server_side_publishing": True,
  253. "production_line_release": True,
  254. "data_factory_activation": readiness["ready"],
  255. "data_factory_readiness": readiness,
  256. }
  257. )
  258. )
  259. @bp.post("/production-lines/draft-identity")
  260. def create_production_line_draft_identity():
  261. """Persist a short-lived, single-use, actor-bound DataFlow reservation."""
  262. try:
  263. _closed_body(set())
  264. result = _repository().reserve_dataflow_draft(
  265. actor_uid=g.current_user["id"]
  266. )
  267. db.session.commit()
  268. return (
  269. jsonify(
  270. success(
  271. result,
  272. "生产线草稿身份已创建",
  273. code=201,
  274. )
  275. ),
  276. 201,
  277. )
  278. except (TypeError, ValueError):
  279. db.session.rollback()
  280. return _bad_request("生产线草稿身份请求无效")
  281. except Exception:
  282. db.session.rollback()
  283. current_app.logger.exception("create DataFlow draft reservation failed")
  284. return jsonify(failed("生产线草稿身份暂时不可用", code=503)), 503
  285. @bp.post("/validate")
  286. def validate_asset():
  287. try:
  288. body = _closed_body({"asset_type", "spec"})
  289. asset_type = body.get("asset_type")
  290. if asset_type not in _VALIDATORS:
  291. raise ValueError("unsupported asset type")
  292. validator, hasher = _VALIDATORS[asset_type]
  293. normalized = validator(body.get("spec"))
  294. return jsonify(
  295. success(
  296. {
  297. "asset_type": asset_type,
  298. "normalized": normalized,
  299. "spec_hash": hasher(normalized),
  300. }
  301. )
  302. )
  303. except (TypeError, ValueError):
  304. return _bad_request("规则定义无效")
  305. def _authoring_agent() -> RuleAuthoringAgent:
  306. configured = current_app.extensions.get("data_rule_authoring_agent")
  307. if configured is not None:
  308. return configured
  309. api_key = current_app.config.get("LLM_API_KEY") or current_app.config.get(
  310. "DEEPSEEK_API_KEY"
  311. )
  312. if not api_key:
  313. raise RuntimeError("rule authoring model is not configured")
  314. agent = RuleAuthoringAgent(model=OpenAICompatibleRuleModel())
  315. current_app.extensions["data_rule_authoring_agent"] = agent
  316. return agent
  317. @bp.post("/interpret")
  318. def interpret_rule():
  319. try:
  320. body = _closed_body({"source_text", "authoring_surface", "context"})
  321. receipt_signer = _receipt_signer()
  322. repository = _repository()
  323. validation_context = repository.resolve_validation_context(
  324. body.get("context", {})
  325. )
  326. result = _authoring_agent().interpret(
  327. source_text=body.get("source_text"),
  328. authoring_surface=body.get("authoring_surface"),
  329. context=validation_context,
  330. )
  331. candidate = result.get("candidate")
  332. if (
  333. body.get("authoring_surface") == "data_standard"
  334. and (
  335. not isinstance(candidate, dict)
  336. or candidate.get("candidate_type") != "rule"
  337. or not isinstance(candidate.get("rule_spec"), dict)
  338. )
  339. ):
  340. raise ValueError(
  341. "data_standard authoring must produce a rule candidate"
  342. )
  343. audit = repository.record_generation_run(
  344. evidence=result,
  345. created_by=g.current_user["id"],
  346. validation_context=validation_context,
  347. )
  348. result = {
  349. **result,
  350. "generation_run_id": audit["id"],
  351. "correlation_id": audit["correlation_id"],
  352. }
  353. if (
  354. result.get("status") == "ready"
  355. and isinstance(candidate, dict)
  356. and candidate.get("candidate_type") == "rule"
  357. and isinstance(candidate.get("rule_spec"), dict)
  358. ):
  359. claims = generation_receipt_claims(
  360. generation_run_id=audit["id"],
  361. actor_uid=g.current_user["id"],
  362. source_text=result["source_text"],
  363. candidate_hash=result["candidate_hash"],
  364. rule_spec=candidate["rule_spec"],
  365. model_hash=result.get("model_hash")
  366. or _metadata_hash(
  367. {
  368. "provider": result.get("model_provider", "unknown"),
  369. "name": result.get("model_name", "unknown"),
  370. }
  371. ),
  372. prompt_hash=result.get("prompt_hash")
  373. or _metadata_hash(result.get("prompt_version", "unknown")),
  374. context_hash=result["context_hash"],
  375. expires_at=datetime.now(UTC) + timedelta(minutes=10),
  376. )
  377. result["generation_receipt"] = receipt_signer.issue(claims)
  378. db.session.commit()
  379. return jsonify(success(result))
  380. except (TypeError, ValueError):
  381. db.session.rollback()
  382. return _bad_request("自然语言规则描述无效")
  383. except RuntimeError:
  384. db.session.rollback()
  385. return jsonify(failed("AI 规则解析服务未配置", code=503)), 503
  386. except Exception:
  387. db.session.rollback()
  388. current_app.logger.exception("AI rule interpretation failed")
  389. return jsonify(failed("AI 规则解析暂时不可用", code=503)), 503
  390. @bp.post("/rule-versions")
  391. def create_rule_version():
  392. try:
  393. body = _closed_body(
  394. {
  395. "rule_spec",
  396. "source_text",
  397. "generation_receipt",
  398. "category",
  399. "source_language",
  400. "generated_kind",
  401. }
  402. )
  403. # Enforce V2 at the HTTP boundary even when a test/different
  404. # repository implementation is injected.
  405. rule_spec = validate_rule_spec(body.get("rule_spec"))
  406. result = _publication_service().create_draft(
  407. rule_spec=rule_spec,
  408. source_text=body.get("source_text"),
  409. category=body.get("category", "general"),
  410. source_language=body.get("source_language", "zh-CN"),
  411. generated_kind=body.get("generated_kind", "rulespec"),
  412. actor_uid=g.current_user["id"],
  413. generation_receipt=body.get("generation_receipt"),
  414. )
  415. db.session.commit()
  416. return jsonify(success(result, "规则版本创建成功")), 201
  417. except (TypeError, ValueError):
  418. db.session.rollback()
  419. return _bad_request("规则版本定义无效")
  420. except Exception:
  421. db.session.rollback()
  422. current_app.logger.exception("create rule version failed")
  423. return jsonify(failed("规则版本创建失败", code=500)), 500
  424. @bp.post("/rule-versions/<version_id>/publish")
  425. def publish_rule_version(version_id: str):
  426. try:
  427. if request.get_data(cache=True) and request.get_json(
  428. silent=True
  429. ) not in (
  430. None,
  431. {},
  432. ):
  433. raise ValueError("publish request must not contain fields")
  434. result = _publication_service().publish(
  435. version_id, g.current_user["id"]
  436. )
  437. db.session.commit()
  438. return jsonify(success(result, "规则版本发布成功"))
  439. except (TypeError, ValueError):
  440. db.session.rollback()
  441. return jsonify(failed("规则版本无法发布", code=409)), 409
  442. except Exception:
  443. db.session.rollback()
  444. current_app.logger.exception("publish rule version failed")
  445. return jsonify(failed("规则版本发布失败", code=500)), 500
  446. @bp.post("/rule-versions/<version_id>/validate")
  447. def validate_rule_version(version_id: str):
  448. try:
  449. if request.get_data(cache=True) and request.get_json(
  450. silent=True
  451. ) not in (
  452. None,
  453. {},
  454. ):
  455. raise ValueError("validate request must not contain fields")
  456. result = _publication_service().validate(
  457. version_id, g.current_user["id"]
  458. )
  459. db.session.commit()
  460. return jsonify(success(result, "规则版本编译验证成功"))
  461. except RuleValidationRejected:
  462. db.session.commit()
  463. return jsonify(failed("规则版本编译验证失败", code=409)), 409
  464. except (TypeError, ValueError):
  465. db.session.rollback()
  466. return jsonify(failed("规则版本编译验证失败", code=409)), 409
  467. except RuntimeError:
  468. db.session.rollback()
  469. return jsonify(failed("规则验证服务未配置", code=503)), 503
  470. @bp.post("/rule-versions/<version_id>/test")
  471. def test_rule_version(version_id: str):
  472. try:
  473. body = _closed_body({"plan_id"})
  474. result = _publication_service().test(
  475. version_id,
  476. g.current_user["id"],
  477. plan_id=body.get("plan_id"),
  478. )
  479. db.session.commit()
  480. return jsonify(success(result, "规则版本样本测试成功"))
  481. except (TypeError, ValueError):
  482. db.session.rollback()
  483. return jsonify(failed("规则版本样本测试失败", code=409)), 409
  484. except RuntimeError:
  485. db.session.rollback()
  486. return jsonify(failed("规则测试服务未配置", code=503)), 503
  487. @bp.get("/rule-versions/<version_id>/evidence")
  488. def rule_version_evidence(version_id: str):
  489. try:
  490. return jsonify(
  491. success(
  492. _repository().get_asset_evidence(
  493. asset_type="rule", version_id=version_id
  494. )
  495. )
  496. )
  497. except (TypeError, ValueError):
  498. return jsonify(failed("规则证据不存在", code=404)), 404
  499. except Exception:
  500. current_app.logger.exception("load rule evidence failed")
  501. return jsonify(failed("规则证据暂时不可用", code=503)), 503
  502. @bp.get("/catalog/assets/<asset_type>/<version_id>/evidence")
  503. def catalog_asset_evidence(asset_type: str, version_id: str):
  504. try:
  505. return jsonify(
  506. success(
  507. _repository().get_asset_evidence(
  508. asset_type=asset_type,
  509. version_id=version_id,
  510. )
  511. )
  512. )
  513. except (TypeError, ValueError):
  514. return jsonify(failed("资产证据不存在", code=404)), 404
  515. except Exception:
  516. current_app.logger.exception("load catalog asset evidence failed")
  517. return jsonify(failed("资产证据暂时不可用", code=503)), 503
  518. def _catalog_schema_context():
  519. raw_inputs = request.args.get("input_schema_refs")
  520. output = request.args.get("output_schema_ref")
  521. if raw_inputs is None and output is None:
  522. return None, None
  523. if raw_inputs is None or output is None:
  524. raise ValueError("catalog schema context is incomplete")
  525. inputs = json.loads(raw_inputs)
  526. if not isinstance(inputs, list):
  527. raise ValueError("catalog input_schema_refs must be an array")
  528. return inputs, output
  529. @bp.get("/catalog/assets/<asset_type>/<version_id>")
  530. def catalog_asset(asset_type: str, version_id: str):
  531. try:
  532. if set(request.args) - {
  533. "input_schema_refs",
  534. "output_schema_ref",
  535. }:
  536. raise ValueError("catalog asset query contains unsupported fields")
  537. inputs, output = _catalog_schema_context()
  538. result = _repository().get_published_asset(
  539. asset_type=asset_type,
  540. version_id=version_id,
  541. input_schema_refs=inputs,
  542. output_schema_ref=output,
  543. schema_resolver=_schema_resolver() if inputs is not None else None,
  544. )
  545. if inputs is not None:
  546. # SchemaResolver is a read-through snapshot boundary. Persist the
  547. # IDs returned to this response before making them observable.
  548. db.session.commit()
  549. return jsonify(success(result))
  550. except (TypeError, ValueError, json.JSONDecodeError):
  551. db.session.rollback()
  552. return jsonify(failed("已发布资产不存在或上下文无效", code=404)), 404
  553. except Exception:
  554. db.session.rollback()
  555. current_app.logger.exception("load exact catalog asset failed")
  556. return jsonify(failed("规则目录暂时不可用", code=503)), 503
  557. @bp.get("/catalog")
  558. @bp.get("/catalog/rule-versions")
  559. def published_rule_catalog():
  560. try:
  561. legacy_rule_alias = request.path.endswith("/rule-versions")
  562. allowed = {
  563. "query",
  564. "limit",
  565. "offset",
  566. "input_schema_refs",
  567. "output_schema_ref",
  568. }
  569. if not legacy_rule_alias:
  570. allowed.add("asset_type")
  571. if set(request.args) - allowed:
  572. raise ValueError("catalog query contains unsupported fields")
  573. query = request.args.get("query", "")
  574. limit = int(request.args.get("limit", "50"))
  575. offset = int(request.args.get("offset", "0"))
  576. asset_type = (
  577. "rule"
  578. if legacy_rule_alias
  579. else request.args.get("asset_type") or None
  580. )
  581. if asset_type not in {None, "rule", "standard"}:
  582. raise ValueError("catalog asset_type is invalid")
  583. if len(query) > 200 or limit < 1 or limit > 100:
  584. raise ValueError("catalog bounds are invalid")
  585. if offset < 0 or offset > 1_000_000:
  586. raise ValueError("catalog offset is invalid")
  587. inputs, output = _catalog_schema_context()
  588. catalog_args = {
  589. "query": query,
  590. "asset_type": asset_type,
  591. "limit": limit,
  592. "offset": offset,
  593. }
  594. if inputs is not None:
  595. catalog_args.update(
  596. {
  597. "input_schema_refs": inputs,
  598. "output_schema_ref": output,
  599. "schema_resolver": _schema_resolver(),
  600. }
  601. )
  602. result = _repository().search_published_assets(**catalog_args)
  603. if inputs is not None:
  604. db.session.commit()
  605. return jsonify(success(result))
  606. except (TypeError, ValueError):
  607. db.session.rollback()
  608. return _bad_request("规则目录查询无效")
  609. except Exception:
  610. db.session.rollback()
  611. current_app.logger.exception("load rule catalog failed")
  612. return jsonify(failed("规则目录暂时不可用", code=503)), 503
  613. @bp.post("/execution-plans/<plan_id>/validate")
  614. def validate_physical_plan(plan_id: str):
  615. try:
  616. if request.get_data(cache=True) and request.get_json(
  617. silent=True
  618. ) not in (
  619. None,
  620. {},
  621. ):
  622. raise ValueError("validate request must not contain fields")
  623. result = _physical_publication_service().validate(
  624. plan_id, g.current_user["id"]
  625. )
  626. db.session.commit()
  627. return jsonify(success(result, "物理执行计划编译证据已确认"))
  628. except (TypeError, ValueError):
  629. db.session.rollback()
  630. return jsonify(failed("物理执行计划验证失败", code=409)), 409
  631. except RuntimeError:
  632. db.session.rollback()
  633. return jsonify(failed("物理计划验证服务未配置", code=503)), 503
  634. @bp.post("/execution-plans/<plan_id>/test")
  635. def test_physical_plan(plan_id: str):
  636. try:
  637. if request.get_data(cache=True) and request.get_json(
  638. silent=True
  639. ) not in (
  640. None,
  641. {},
  642. ):
  643. raise ValueError("test request must not contain fields")
  644. result = _physical_publication_service().test(
  645. plan_id, g.current_user["id"]
  646. )
  647. db.session.commit()
  648. return jsonify(success(result, "物理执行计划样本测试成功"))
  649. except (TypeError, ValueError):
  650. db.session.rollback()
  651. return jsonify(failed("物理执行计划样本测试失败", code=409)), 409
  652. except RuntimeError:
  653. db.session.rollback()
  654. return jsonify(failed("物理计划测试服务未配置", code=503)), 503
  655. @bp.post("/execution-plans/<plan_id>/publish")
  656. def publish_physical_plan(plan_id: str):
  657. try:
  658. if request.get_data(cache=True) and request.get_json(
  659. silent=True
  660. ) not in (
  661. None,
  662. {},
  663. ):
  664. raise ValueError("publish request must not contain fields")
  665. result = _physical_publication_service().publish(
  666. plan_id, g.current_user["id"]
  667. )
  668. db.session.commit()
  669. return jsonify(success(result, "物理执行计划发布成功"))
  670. except (TypeError, ValueError):
  671. db.session.rollback()
  672. return jsonify(failed("物理执行计划无法发布", code=409)), 409
  673. except RuntimeError:
  674. db.session.rollback()
  675. return jsonify(failed("物理计划发布服务未配置", code=503)), 503
  676. @bp.post("/standard-versions")
  677. def create_standard_version():
  678. try:
  679. body = _closed_body({"standard_spec", "source_text"})
  680. result = _repository().create_standard_version(
  681. standard_spec=body.get("standard_spec"),
  682. source_text=body.get("source_text"),
  683. created_by=g.current_user["id"],
  684. )
  685. db.session.commit()
  686. return jsonify(success(result, "数据标准版本创建成功")), 201
  687. except (TypeError, ValueError):
  688. db.session.rollback()
  689. return _bad_request("数据标准版本定义无效")
  690. except Exception:
  691. db.session.rollback()
  692. current_app.logger.exception("create standard version failed")
  693. return jsonify(failed("数据标准版本创建失败", code=500)), 500
  694. @bp.post("/standard-versions/<version_id>/publish")
  695. def publish_standard_version(version_id: str):
  696. try:
  697. if request.get_data(cache=True) and request.get_json(
  698. silent=True
  699. ) not in (
  700. None,
  701. {},
  702. ):
  703. raise ValueError("publish request must not contain fields")
  704. result = _repository().publish_standard_version(
  705. version_id=version_id,
  706. published_by=g.current_user["id"],
  707. )
  708. db.session.commit()
  709. return jsonify(success(result, "数据标准版本发布成功"))
  710. except (TypeError, ValueError):
  711. db.session.rollback()
  712. return jsonify(failed("数据标准版本无法发布", code=409)), 409
  713. except Exception:
  714. db.session.rollback()
  715. current_app.logger.exception("publish standard version failed")
  716. return jsonify(failed("数据标准版本发布失败", code=500)), 500
  717. @bp.post("/production-lines/resolve")
  718. def resolve_production_line_preview():
  719. try:
  720. body = _body()
  721. package = resolve_production_line(
  722. body.get("dataflow_spec"),
  723. body.get("standard_versions"),
  724. body.get("rule_versions"),
  725. component_binding_ids=body.get("component_binding_ids"),
  726. )
  727. return jsonify(
  728. success(
  729. {
  730. "preview": True,
  731. "release_ready": False,
  732. "package": package,
  733. }
  734. )
  735. )
  736. except (TypeError, ValueError):
  737. return _bad_request("数据生产线定义无效")
  738. @bp.post("/production-lines/<dataflow_uid>/release")
  739. def release_production_line(dataflow_uid: str):
  740. try:
  741. body = _closed_body(
  742. {
  743. "dataflow_spec",
  744. "source_text",
  745. }
  746. )
  747. result = _release_service().release(
  748. dataflow_uid=dataflow_uid,
  749. dataflow_spec=body.get("dataflow_spec"),
  750. source_text=body.get("source_text"),
  751. created_by=g.current_user["id"],
  752. )
  753. db.session.commit()
  754. return jsonify(success(result, "数据生产线发布成功")), 201
  755. except (TypeError, ValueError):
  756. db.session.rollback()
  757. return jsonify(failed("数据生产线无法发布", code=409)), 409
  758. except Exception:
  759. db.session.rollback()
  760. current_app.logger.exception("release production line failed")
  761. return jsonify(failed("数据生产线发布失败", code=500)), 500
  762. @bp.get("/deployments")
  763. def list_dataflow_deployments():
  764. try:
  765. if set(request.args) - {"environment"}:
  766. raise ValueError("deployment query contains unsupported fields")
  767. return jsonify(
  768. success(
  769. {
  770. "items": _deployment_service().list(
  771. environment=request.args.get("environment") or None
  772. ),
  773. "readiness": _deployment_readiness(),
  774. }
  775. )
  776. )
  777. except (TypeError, ValueError):
  778. return _bad_request("数据工厂投产查询无效")
  779. except RuntimeError:
  780. return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
  781. @bp.get("/deployments/candidates")
  782. def list_dataflow_deployment_candidates():
  783. try:
  784. if set(request.args) != {"environment"}:
  785. raise ValueError("candidate environment is required")
  786. return jsonify(
  787. success(
  788. {
  789. "items": _deployment_service().candidates(
  790. environment=request.args["environment"]
  791. )
  792. }
  793. )
  794. )
  795. except (TypeError, ValueError):
  796. return _bad_request("可入厂生产线查询无效")
  797. except RuntimeError:
  798. return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
  799. @bp.post("/deployments")
  800. def create_dataflow_deployment():
  801. try:
  802. body = _closed_body(
  803. {
  804. "dataflow_version_id",
  805. "environment",
  806. "binding_snapshot",
  807. "schedule_plan",
  808. "reason",
  809. "idempotency_key",
  810. "correlation_id",
  811. }
  812. )
  813. result = _deployment_service().create(
  814. body.get("dataflow_version_id"),
  815. binding_snapshot=body.get("binding_snapshot"),
  816. environment=body.get("environment"),
  817. schedule_plan=body.get("schedule_plan"),
  818. actor_uid=g.current_user["id"],
  819. reason=body.get("reason"),
  820. idempotency_key=body.get("idempotency_key"),
  821. correlation_id=body.get("correlation_id"),
  822. )
  823. db.session.commit()
  824. return jsonify(success(result, "数据生产线已进入数据工厂")), 201
  825. except (TypeError, ValueError):
  826. db.session.rollback()
  827. return jsonify(failed("数据工厂投产定义无效", code=409)), 409
  828. except RuntimeError:
  829. db.session.rollback()
  830. return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
  831. except Exception:
  832. db.session.rollback()
  833. current_app.logger.exception("create dataflow deployment failed")
  834. return jsonify(failed("数据工厂投产创建失败", code=500)), 500
  835. def _deployment_action_body(extra: set[str] | None = None):
  836. return _closed_body(
  837. {
  838. "reason",
  839. "idempotency_key",
  840. "correlation_id",
  841. *(extra or set()),
  842. }
  843. )
  844. @bp.post("/deployments/<deployment_id>/deploy-disabled")
  845. def deploy_dataflow_disabled(deployment_id: str):
  846. try:
  847. body = _deployment_action_body()
  848. result = _deployment_service().deploy_disabled(
  849. deployment_id,
  850. g.current_user["id"],
  851. reason=body.get("reason", "deploy disabled"),
  852. idempotency_key=body.get("idempotency_key"),
  853. correlation_id=body.get("correlation_id"),
  854. )
  855. db.session.commit()
  856. return jsonify(success(result, "生产线已禁用部署"))
  857. except (TypeError, ValueError):
  858. db.session.rollback()
  859. return jsonify(failed("生产线无法禁用部署", code=409)), 409
  860. except RuntimeError:
  861. db.session.rollback()
  862. return jsonify(failed("调度引擎部署结果异常", code=503)), 503
  863. @bp.post("/deployments/<deployment_id>/canary")
  864. def run_dataflow_canary(deployment_id: str):
  865. try:
  866. body = _deployment_action_body({"inputs"})
  867. result = _deployment_service().run_canary(
  868. deployment_id,
  869. body.get("inputs", {}),
  870. g.current_user["id"],
  871. reason=body.get("reason", "trial production"),
  872. idempotency_key=body.get("idempotency_key"),
  873. correlation_id=body.get("correlation_id"),
  874. )
  875. db.session.commit()
  876. return jsonify(success(result, "试生产已完成"))
  877. except (TypeError, ValueError):
  878. db.session.rollback()
  879. return jsonify(failed("试生产请求无效", code=409)), 409
  880. except RuntimeError:
  881. db.session.rollback()
  882. return jsonify(failed("试生产执行结果异常", code=503)), 503
  883. @bp.post("/deployments/<deployment_id>/activate")
  884. def activate_dataflow_deployment(deployment_id: str):
  885. if not _activation_gate_ready():
  886. return jsonify(failed("数据工厂激活门禁尚未开放", code=503)), 503
  887. try:
  888. body = _deployment_action_body({"evidence_id"})
  889. result = _deployment_service().activate(
  890. deployment_id,
  891. body.get("evidence_id"),
  892. g.current_user["id"],
  893. reason=body.get("reason", "mass production activation"),
  894. idempotency_key=body.get("idempotency_key"),
  895. correlation_id=body.get("correlation_id"),
  896. )
  897. db.session.commit()
  898. return jsonify(success(result, "生产线已转入批量生产"))
  899. except (TypeError, ValueError):
  900. db.session.rollback()
  901. return jsonify(failed("生产线无法激活", code=409)), 409
  902. except RuntimeError:
  903. db.session.rollback()
  904. return jsonify(failed("生产线激活结果异常", code=503)), 503
  905. @bp.post("/deployments/<deployment_id>/rollback")
  906. def rollback_dataflow_deployment(deployment_id: str):
  907. try:
  908. body = _deployment_action_body()
  909. result = _deployment_service().rollback(
  910. deployment_id,
  911. g.current_user["id"],
  912. reason=body.get("reason", "restore previous production line"),
  913. idempotency_key=body.get("idempotency_key"),
  914. correlation_id=body.get("correlation_id"),
  915. )
  916. db.session.commit()
  917. return jsonify(success(result, "已恢复上一条稳定生产线"))
  918. except (TypeError, ValueError):
  919. db.session.rollback()
  920. return jsonify(failed("生产线无法回滚", code=409)), 409
  921. except RuntimeError:
  922. db.session.rollback()
  923. return jsonify(failed("生产线回滚结果异常", code=503)), 503
  924. def _reconcile_dataflow_deployment(deployment_id: str, action: str):
  925. try:
  926. body = _closed_body({"idempotency_key"})
  927. result = _deployment_service().reconcile(
  928. deployment_id,
  929. action,
  930. g.current_user["id"],
  931. idempotency_key=body.get("idempotency_key"),
  932. )
  933. db.session.commit()
  934. return jsonify(success(result, "投产未知结果已完成对账"))
  935. except PermissionError:
  936. db.session.rollback()
  937. return jsonify(failed("权限不足", code=403)), 403
  938. except (TypeError, ValueError):
  939. db.session.rollback()
  940. return jsonify(failed("投产未知结果无法对账", code=409)), 409
  941. except RuntimeError:
  942. db.session.rollback()
  943. return jsonify(failed("投产对账服务异常", code=503)), 503
  944. @bp.post("/deployments/<deployment_id>/reconcile-deploy")
  945. def reconcile_dataflow_deploy(deployment_id: str):
  946. return _reconcile_dataflow_deployment(
  947. deployment_id, "deploy_disabled"
  948. )
  949. @bp.post("/deployments/<deployment_id>/reconcile-activate")
  950. def reconcile_dataflow_activate(deployment_id: str):
  951. return _reconcile_dataflow_deployment(deployment_id, "activate")
  952. @bp.post("/deployments/<deployment_id>/reconcile-rollback")
  953. def reconcile_dataflow_rollback(deployment_id: str):
  954. return _reconcile_dataflow_deployment(deployment_id, "rollback")