feat: 待办输出文件给打开/下载链接——新增project_file.dspy按项目ID解析文件(路径穿越+机构隔离校验),待办详情文件区渲染链接按钮

This commit is contained in:
ymq 2026-09-02 11:46:09 +08:00
parent 9eca0ca3d9
commit 36c4132b02
2 changed files with 100 additions and 2 deletions

View File

@ -0,0 +1,64 @@
# project_file.dspy - 按项目ID提供产出文件的打开/下载(待办里跨项目直达交付件文件)
# 入参project_id=<项目id>path=<文件路径>(项目目录内相对路径 或 项目目录内的绝对路径),
# download=1 强制下载。
# 安全:仅登录用户;解析后校验文件真实路径必须落在该项目目录内(防路径穿越/跨项目越权)。
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": "请先登录"}}
project_id = ((params_kw or {}).get('project_id') or '').strip()
fpath = ((params_kw or {}).get('path') or '').strip()
download = ((params_kw or {}).get('download') or '').strip()
if not project_id:
return {"widgettype": "Message", "options": {"title": "错误", "message": "缺少 project_id"}}
if not fpath:
return {"widgettype": "Message", "options": {"title": "错误", "message": "缺少文件路径"}}
dbname = get_module_dbname('pipeline-sdlc')
# 按项目ID解析项目目录不依赖会话上下文并做机构隔离校验
async with DBPools().sqlorContext(dbname) as sor:
precs = await sor.sqlExe(
"SELECT id, name, org_id FROM sd_projects WHERE id=${p}$ LIMIT 1", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if not precs:
return {"widgettype": "Message", "options": {"title": "错误", "message": "项目不存在"}}
proj_org = getattr(precs[0], 'org_id', '0') or '0'
project_dir, workspace_base = await get_project_dir_by_id(sor, project_id)
if not project_dir or not os.path.isdir(project_dir):
return {"widgettype": "Message", "options": {"title": "错误", "message": "项目目录不可用"}}
# 机构隔离:非超管(0)只能访问本机构项目项目为通用机构0时登录即可见
try:
uorg = await get_userorgid()
except Exception:
uorg = ''
if str(uorg) != '0' and str(proj_org) != '0' and str(uorg) != str(proj_org):
return {"widgettype": "Message", "options": {"title": "无权限", "message": "无权访问其他机构的项目文件"}}
# 路径解析:绝对路径直接用;相对路径拼项目目录
cand = fpath if fpath.startswith('/') else os.path.join(project_dir, fpath)
# 路径穿越校验:真实路径必须落在项目目录内
real_proj = os.path.realpath(project_dir)
real_full = os.path.realpath(cand)
if not real_full.startswith(real_proj + os.sep):
return {"widgettype": "Message", "options": {"title": "错误", "message": "非法路径(不在项目空间内)"}}
if not os.path.isfile(real_full):
return {"widgettype": "Message", "options": {"title": "文件不存在",
"message": "文件未找到:" + os.path.basename(cand) + "(可能尚未生成或已删除)"}}
headers = {}
if download:
filename = os.path.basename(real_full)
safe_name = quote(filename)
headers['Content-Disposition'] = 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (filename, safe_name)
return FileResponse(real_full, headers=headers)

View File

@ -3,6 +3,8 @@
# 入参kind=human_task|question, id=<待办id>
import json as _json
import os
from urllib.parse import quote as _quote
user_id = await get_user()
if not user_id:
@ -86,6 +88,24 @@ def _btn(label, css, script, conform=None):
return {"widgettype": "Button", "options": {"label": label, "css": css}, "binds": [bind]}
def _file_link_row(fname, project_id, fpath):
"""待办中的输出文件行:文件名 + 打开/下载按钮(链接到项目空间内的真实文件)。"""
open_url = (entire_url("/pipeline-sdlc/api/project_file.dspy")
+ "?project_id=" + _quote(project_id) + "&path=" + _quote(fpath))
dl_url = open_url + "&download=1"
return {"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "6px 14px",
"alignItems": "center", "bgcolor": "#f8fafc",
"border": "1px solid #e2e8f0", "borderRadius": "6px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "📎 " + fname,
"cfontsize": 0.85, "color": "#334155", "flex": "1 1 auto",
"overflow": "hidden", "textOverflow": "ellipsis", "whiteSpace": "nowrap"}},
_btn("打开", "small", "window.open(" + _json.dumps(open_url) + ",'_blank');"),
_btn("下载", "small", "window.open(" + _json.dumps(dl_url) + ",'_blank');")
]}
def _build_form_action(schema_fields, kind, oid):
"""按 form_schema 生成动态表单操作区(问题通道与人类任务通道通用)。
@ -174,6 +194,7 @@ badge_color = '#64748b'
sub_lines = []
md = []
action_widgets = []
_file_rows = [] # 输出文件链接行(打开/下载)——待办中出现的输出文件必须可直达
async with DBPools().sqlorContext(dbname) as sor:
project_id = ''
@ -320,7 +341,8 @@ async with DBPools().sqlorContext(dbname) as sor:
+ ' 评审:' + (_s(getattr(dx, 'review_status', '')) or '-'))
_fp = _s(getattr(dx, 'file_path', ''))
if _fp:
md.append('- 文件:`' + _fp + '`')
md.append('- 输出文件:`' + os.path.basename(_fp) + '`(项目空间 deliverables 目录)')
_file_rows.append(_file_link_row(os.path.basename(_fp), project_id, _fp))
md.append('')
md.append('---')
md.append('')
@ -520,6 +542,18 @@ if sub_lines:
"padding": "0 14px 6px 14px", "width": "100%"}
})
# 输出文件区(待办中出现的输出文件必须给打开/下载链接)
_file_section = []
if _file_rows:
_file_section = [{
"widgettype": "VBox",
"options": {"width": "100%", "gap": "6px", "padding": "8px 14px 2px 14px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "📎 输出文件", "cfontsize": 0.85,
"fontWeight": "bold", "color": "#334155"}}
] + _file_rows
}]
return {
"widgettype": "PopupWindow",
"id": "todo_detail_pw",
@ -544,6 +578,6 @@ return {
}]
}]
}
] + action_widgets
] + _file_section + action_widgets
}]
}