用户需求(2026-09-09):研发报告按钮点击按项目owner过滤列表;每项点击看
研发场景/输入/输出/成果;成果文件多个列出、逐个点击下载。
1. 列表页 api/opp_reports_list.dspy(替换CRUD裸表页作菜单入口):
- list_visible_reports 单一事实源:挂项目的报告仅项目owner可见
(sd_projects.created_by==uid;agent建的项目降级同机构,对齐
check_project_owner既有语义);未挂项目的平台级调研登录可见;
孤儿报告(项目已删)不可见(LIKE 'agent.%' 写法有 rag_client.py 生产先例)
- 整行点击下钻详情,交互范式照抄 opp_references_items.dspy
2. 详情弹窗 api/opp_report_detail.dspy:
- parse_report_sections 四节解析(## 研发场景/输入/输出/成果),
MdWidget逐节渲染;兼容序号/冒号/加粗标题变体;三级标题不误判;
旧报告无节时全文归输出节;空节如实标注不编造
- check_report_visible 与列表同一可见性规则(语义不漂移)
3. 文件下载 api/opp_report_file_dl.dspy + list_report_files:
- 范围=项目deliverables/全部文件+报告PPT(realpath去重)
- 安全模型对齐 opp_attachment_dl:前端只传report_id+name,
服务端回查清单命中才给文件,文件名不参与路径拼接(防穿越)
4. agent侧四节模板硬要求:OPP_PROMPT新增节+create/update_report工具描述
5. RBAC load_path 补3端点;i18n zh/en 补9词条;README 补改造说明
离线实测11项断言全过:标准四节/标题变体/旧报告归输出/缺节/三级标题不误判/
重复节拼接/空正文/deliverables扫描+PPT去重/项目外PPT/无项目不崩/防穿越
53 lines
2.3 KiB
Plaintext
53 lines
2.3 KiB
Plaintext
# 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)
|