274 lines
9.6 KiB
Python
274 lines
9.6 KiB
Python
"""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
|