feat: prepare ARR for controlled public deployment
This commit is contained in:
273
arr_web/services.py
Normal file
273
arr_web/services.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Mutation seams for uploads and deterministic monthly report generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Protocol
|
||||
|
||||
from arr_ingestion.contracts import IngestionError
|
||||
from arr_ingestion.direct_contracts import SubmissionGrant
|
||||
from arr_ingestion.repository import JobRegistration
|
||||
from arr_processing.callbacks import AgentResultWriteback
|
||||
from arr_processing.contracts import ProcessingRequest
|
||||
from arr_processing.errors import ProcessingError
|
||||
from arr_processing.runner import processing_dispatch_idempotency_key
|
||||
from arr_storage.store import ManagedObjectStore
|
||||
from arr_web.contracts import (
|
||||
PortalError,
|
||||
validate_upload_filename,
|
||||
validate_xml_payload,
|
||||
)
|
||||
from monthly_reports.service import MonthlyReportService, RunRequest
|
||||
|
||||
|
||||
class UploadCoordinator(Protocol):
|
||||
def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
class MonthlyCoordinator(Protocol):
|
||||
def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
class AgentResultCoordinator(Protocol):
|
||||
def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
class ProcessingStarter(Protocol):
|
||||
def start(self, request: ProcessingRequest) -> Any:
|
||||
...
|
||||
|
||||
|
||||
class DirectJobRepository(Protocol):
|
||||
def register_job(self, registration: JobRegistration) -> None:
|
||||
...
|
||||
|
||||
def issue_grant(
|
||||
self,
|
||||
job_id: str,
|
||||
attempt_no: int,
|
||||
*,
|
||||
ttl_seconds: int,
|
||||
) -> SubmissionGrant:
|
||||
...
|
||||
|
||||
|
||||
class UnavailableUploadCoordinator:
|
||||
"""Production-safe default until private OSS and the remote file API are wired."""
|
||||
|
||||
def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
|
||||
raise PortalError(
|
||||
"PROCESSING_GATEWAY_UNAVAILABLE",
|
||||
"文件接收服务尚未完成生产接线",
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
class UnavailableMonthlyCoordinator:
|
||||
def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]:
|
||||
raise PortalError(
|
||||
"MONTHLY_GENERATOR_UNAVAILABLE",
|
||||
"月报生成服务暂不可用",
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
class UnavailableAgentResultCoordinator:
|
||||
def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
|
||||
raise PortalError(
|
||||
"AGENT_WRITEBACK_UNAVAILABLE",
|
||||
"Agent 结果回写服务尚未启用",
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingAgentResultCoordinator:
|
||||
"""Web-safe adapter around the authenticated ARR result pipeline."""
|
||||
|
||||
writeback: AgentResultWriteback
|
||||
|
||||
def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
|
||||
try:
|
||||
return self.writeback.accept(raw_signed_result).to_dict()
|
||||
except (IngestionError, ProcessingError) as error:
|
||||
code = error.code
|
||||
if code in {
|
||||
"PROCESSING_SIGNATURE_INVALID",
|
||||
"PROCESSING_SIGNATURE_EXPIRED",
|
||||
}:
|
||||
status = 401
|
||||
message = "Agent 结果签名无效或已过期"
|
||||
elif code.endswith("_NOT_FOUND") or code in {
|
||||
"JOB_NOT_FOUND",
|
||||
"PROCESSING_JOB_NOT_FOUND",
|
||||
}:
|
||||
status = 404
|
||||
message = "Agent 结果对应的业务任务不存在"
|
||||
elif "CONFLICT" in code or code in {
|
||||
"JOB_TERMINAL",
|
||||
"PROCESSING_RESULT_MISMATCH",
|
||||
}:
|
||||
status = 409
|
||||
message = "Agent 结果与现有业务任务冲突"
|
||||
elif getattr(error, "retryable", False) or code.startswith("DATABASE_"):
|
||||
status = 503
|
||||
message = "Agent 结果暂时无法写入,请使用相同回调重试"
|
||||
else:
|
||||
status = 422
|
||||
message = "Agent 结果未通过业务验收"
|
||||
raise PortalError(code, message, status) from None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectStoreUploadCoordinator:
|
||||
"""ARR upload boundary: private object -> DB registration -> remote dispatch."""
|
||||
|
||||
object_store: ManagedObjectStore
|
||||
ingestion_repository: DirectJobRepository
|
||||
processing_starter: ProcessingStarter
|
||||
processor_version: str
|
||||
rule_set_sha256: str
|
||||
submission_grant_ttl_seconds: int = 900
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.submission_grant_ttl_seconds, int)
|
||||
or isinstance(self.submission_grant_ttl_seconds, bool)
|
||||
or not 30 <= self.submission_grant_ttl_seconds <= 1800
|
||||
):
|
||||
raise ValueError("submission grant lifetime is invalid")
|
||||
|
||||
def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
|
||||
validate_upload_filename(original_filename)
|
||||
validate_xml_payload(payload)
|
||||
job_id = "arrjob-" + uuid.uuid4().hex
|
||||
attempt_no = 1
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="arr-web-upload-") as temporary:
|
||||
source_path = Path(temporary) / "source.xml"
|
||||
descriptor = os.open(
|
||||
source_path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as target:
|
||||
descriptor = -1
|
||||
target.write(payload)
|
||||
target.flush()
|
||||
os.fsync(target.fileno())
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
stored = self.object_store.upload_committed(
|
||||
job_id=job_id,
|
||||
attempt_no=attempt_no,
|
||||
role="source_xml",
|
||||
source=source_path,
|
||||
original_filename="source.xml",
|
||||
)
|
||||
source_file_id = "arr_file_" + uuid.uuid4().hex
|
||||
request_without_grant = ProcessingRequest(
|
||||
job_id=job_id,
|
||||
attempt_no=attempt_no,
|
||||
source_file_id=source_file_id,
|
||||
processor_version=self.processor_version,
|
||||
rule_set_sha256=self.rule_set_sha256,
|
||||
)
|
||||
self.ingestion_repository.register_job(
|
||||
JobRegistration(
|
||||
job_id=job_id,
|
||||
source=stored.to_artifact_ref(),
|
||||
processor_version=self.processor_version,
|
||||
rule_set_sha256=self.rule_set_sha256,
|
||||
attempt_no=attempt_no,
|
||||
idempotency_key=processing_dispatch_idempotency_key(
|
||||
request_without_grant
|
||||
),
|
||||
)
|
||||
)
|
||||
grant = self.ingestion_repository.issue_grant(
|
||||
job_id,
|
||||
attempt_no,
|
||||
ttl_seconds=self.submission_grant_ttl_seconds,
|
||||
)
|
||||
if grant.job_id != job_id or grant.attempt_no != attempt_no:
|
||||
raise IngestionError(
|
||||
"SUBMISSION_GRANT_UNAVAILABLE",
|
||||
"direct result submission grant identity is invalid",
|
||||
retryable=True,
|
||||
)
|
||||
request = ProcessingRequest(
|
||||
job_id=job_id,
|
||||
attempt_no=attempt_no,
|
||||
source_file_id=source_file_id,
|
||||
processor_version=self.processor_version,
|
||||
rule_set_sha256=self.rule_set_sha256,
|
||||
submission_grant=grant.submission_grant,
|
||||
)
|
||||
record = self.processing_starter.start(request)
|
||||
except (IngestionError, ProcessingError) as error:
|
||||
status = (
|
||||
503
|
||||
if getattr(error, "retryable", False)
|
||||
or error.code.startswith("DATABASE_")
|
||||
or error.code in {
|
||||
"OBJECT_STORE_UNAVAILABLE",
|
||||
}
|
||||
else 422
|
||||
)
|
||||
raise PortalError(error.code, error.safe_message, status) from None
|
||||
except Exception:
|
||||
raise PortalError(
|
||||
"PROCESSING_SUBMISSION_FAILED",
|
||||
"文件已安全接收,但处理任务未能启动",
|
||||
503,
|
||||
) from None
|
||||
remote_status = str(getattr(record, "remote_status", "queued"))
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "queued" if remote_status in {"reserved", "pending"} else remote_status,
|
||||
"attempt_no": attempt_no,
|
||||
"source_sha256": stored.sha256,
|
||||
"source_byte_size": stored.byte_size,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgramMonthlyCoordinator:
|
||||
"""Runs the ordinary database program; it never invokes an Agent."""
|
||||
|
||||
service: MonthlyReportService
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]:
|
||||
year, month = (int(part) for part in month_key.split("-"))
|
||||
request = RunRequest(year, month, as_of_date)
|
||||
try:
|
||||
request.validate()
|
||||
except Exception:
|
||||
raise PortalError("MONTHLY_REQUEST_INVALID", "月报日期范围无效") from None
|
||||
if not self._lock.acquire(blocking=False):
|
||||
raise PortalError("MONTHLY_ALREADY_RUNNING", "该服务正在生成月报", 409)
|
||||
try:
|
||||
result = self.service.run(request)
|
||||
finally:
|
||||
self._lock.release()
|
||||
payload = result.to_dict()
|
||||
if result.status != "success":
|
||||
error = payload.get("errors", [{}])[0]
|
||||
code = str(error.get("code") or "MONTHLY_GENERATION_FAILED")
|
||||
raise PortalError(code, "月报生成未完成", 422)
|
||||
return payload
|
||||
Reference in New Issue
Block a user