# opp_report_file_dl.dspy - 报告成果文件下载(2026-09-09 用户要求:文件列表逐个点击下载) # 入参:report_id + name(文件名)。安全模型对齐 opp_attachment_dl.dspy: # 1. 登录校验 + check_report_visible(与列表/详情同一 owner 规则) # 2. 用户传的文件名只用于在【服务端生成的清单】里定位命中项,绝不参与路径拼接—— # list_report_files 以项目 workspace_dir/deliverables + ppt_path 为权威来源, # 清单外(../穿越、绝对路径、他项目文件)一律 404 语义拒绝。 import os from urllib.parse import quote from aiohttp.web_fileresponse import FileResponse uid = await get_user() if not uid: return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} report_id = ((params_kw or {}).get("report_id") or "").strip() name = ((params_kw or {}).get("name") or "").strip() if not report_id or not name: return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 report_id 或 name"}} dbname = get_module_dbname('pipeline-opportunity') async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_report_capability import ( check_report_visible, list_report_files) visible, rep = await check_report_visible(sor, report_id, uid) await sor.sqlExe("COMMIT", {}) if not visible: return {"widgettype": "Message", "options": { "title": "无权下载", "message": "报告不存在,或你不是该报告所属项目的 owner"}} files = await list_report_files(sor, rep) # 文件名回查清单(basename 精确匹配;穿越名在清单里不可能出现) target = None for f in files: if f.get("name") == os.path.basename(name): target = f break if not target: return {"widgettype": "Message", "options": { "title": "文件不存在", "message": "该文件不在本报告的成果清单中"}} path = target.get("path") or "" if not path or not os.path.isfile(path): return {"widgettype": "Message", "options": { "title": "文件不存在", "message": "文件已从磁盘移除:%s" % os.path.basename(path)}} filename = os.path.basename(path) safe_name = quote(filename) headers = {'Content-Disposition': 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (filename, safe_name)} return FileResponse(path, headers=headers)