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}"'}, )