192 lines
7.9 KiB
Python
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}"'},
|
|
)
|
|
|