PMFlow/backend/app/api/sstc_api/signals_attachment.py

192 lines
7.9 KiB
Python

from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.signal_core import _as_dict, _build_signal_attachment_download_url, _error_json, _extract_signal_attachments, _parse_node_id, _parse_project_id, _resolve_local_attachment_path, _signal_attachment_storage_root
router = APIRouter(tags=["sstc"])
@router.post("/projects/{project_id}/signals/{signal_id}/attachments")
async def upload_signal_attachment(
project_id: str,
signal_id: str,
file: UploadFile | None = File(default=None),
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
try:
pid = _parse_project_id(project_id)
except HTTPException:
return _error_json(status_code=400, message="project_id 不合法")
try:
sid = _parse_node_id(signal_id, "signal_id")
except HTTPException:
return _error_json(status_code=400, message="signal_id 不合法")
if file is None:
return _error_json(status_code=400, message="请选择要上传的附件")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
project = await svc.get_project(pid)
if project is None:
return _error_json(status_code=404, message="项目不存在")
signals = await svc.get_signals(pid)
signal = next((item for item in signals if int(item.id) == sid), None)
if signal is None:
return _error_json(status_code=404, message="信号不存在或不属于当前项目")
original_file_name = Path(str(file.filename or "")).name.strip()
if not original_file_name:
return _error_json(status_code=400, message="文件名不能为空")
extension = Path(original_file_name).suffix.lower()
attachment_type = _SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS.get(extension)
if attachment_type is None:
return _error_json(status_code=400, message="仅支持 jpg/png/gif/pdf/doc/docx/zip 文件")
mime_type = str(file.content_type or "").strip().lower() or None
if mime_type and mime_type not in _SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES:
return _error_json(status_code=400, message="文件类型不支持,请上传 jpg/png/gif/pdf/doc/docx/zip")
try:
content = await file.read()
except Exception:
return _error_json(status_code=500, message="读取上传文件失败")
if not content:
return _error_json(status_code=400, message="上传文件为空")
if len(content) > _SIGNAL_ATTACHMENT_MAX_BYTES:
return _error_json(status_code=400, message="单文件大小不能超过 20MB")
attachment_id = uuid4().hex
relative_dir = Path(f"tenant-{tenant_id}") / f"project-{pid}" / f"signal-{sid}"
storage_root = _signal_attachment_storage_root()
save_dir = storage_root / relative_dir
save_dir.mkdir(parents=True, exist_ok=True)
saved_file_name = f"{attachment_id}{extension}"
saved_path = save_dir / saved_file_name
try:
saved_path.write_bytes(content)
except Exception:
return _error_json(status_code=500, message="保存附件失败,请稍后重试")
file_key = f"local://{(relative_dir / saved_file_name).as_posix()}"
attachment_payload = {
"id": attachment_id,
"attachment_id": attachment_id,
"attachment_type": attachment_type,
"file_name": original_file_name,
"mime_type": mime_type or "application/octet-stream",
"file_size": len(content),
"file_key": file_key,
"image_key": None,
"media_id": None,
"source": "manual_upload",
"occurred_at": now_cn_aware().isoformat(),
"download_url": _build_signal_attachment_download_url(pid, sid, attachment_id),
}
raw_payload = _as_dict(signal.strategic_analysis)
source_context = raw_payload.get("source_context")
if not isinstance(source_context, dict):
source_context = {}
attachments = source_context.get("attachments")
if not isinstance(attachments, list):
attachments = []
attachments.append(attachment_payload)
source_context["attachments"] = attachments
raw_payload["source_context"] = source_context
signal.strategic_analysis = json.dumps(raw_payload, ensure_ascii=False)
try:
await db.commit()
await db.refresh(signal)
except Exception:
await db.rollback()
try:
saved_path.unlink()
except Exception:
pass
return _error_json(status_code=500, message="上传附件失败,请稍后重试")
return success_response(attachment_payload)
@router.get("/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download")
async def download_signal_attachment(
project_id: str,
signal_id: str,
attachment_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
sid = _parse_node_id(signal_id, "signal_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
signals = await svc.get_signals(pid)
signal = next((item for item in signals if int(item.id) == sid), None)
if signal is None:
raise HTTPException(status_code=404, detail="Signal not found")
attachments = _extract_signal_attachments(signal)
target = next(
(
item
for item in attachments
if str(item.get("attachment_id") or "").strip() == attachment_id
or str(item.get("id") or "").strip() == attachment_id
),
None,
)
if target is None:
raise HTTPException(status_code=404, detail="Attachment not found")
local_path = _resolve_local_attachment_path(str(target.get("file_key") or ""))
if local_path is not None:
if not local_path.exists() or not local_path.is_file():
raise HTTPException(status_code=404, detail="附件文件不存在")
try:
content = local_path.read_bytes()
except Exception as exc:
raise HTTPException(status_code=500, detail=f"附件读取失败: {str(exc)}") from exc
if not content:
raise HTTPException(status_code=500, detail="附件内容为空")
file_name = str(target.get("file_name") or f"attachment-{attachment_id}").strip().replace('"', "'")
media_type = str(target.get("mime_type") or "application/octet-stream")
return StreamingResponse(
BytesIO(content),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{file_name}"'},
)
feishu_service = get_feishu_service(db)
await feishu_service.load_config(tenant_id)
if not feishu_service.is_configured():
raise HTTPException(status_code=503, detail="飞书集成未配置,暂无法下载附件")
try:
payload = await feishu_service.download_attachment_bytes(target)
except ValueError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"附件下载失败: {str(exc)}") from exc
file_name = str(payload.get("file_name") or target.get("file_name") or f"attachment-{attachment_id}").strip()
file_name = file_name.replace('"', "'")
media_type = str(payload.get("content_type") or target.get("mime_type") or "application/octet-stream")
content = payload.get("content")
if not isinstance(content, (bytes, bytearray)):
raise HTTPException(status_code=500, detail="附件内容为空")
return StreamingResponse(
BytesIO(bytes(content)),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{file_name}"'},
)