feat: 待办输出文件给打开/下载链接——新增project_file.dspy按项目ID解析文件(路径穿越+机构隔离校验),待办详情文件区渲染链接按钮
This commit is contained in:
parent
9eca0ca3d9
commit
36c4132b02
64
wwwroot/api/project_file.dspy
Normal file
64
wwwroot/api/project_file.dspy
Normal 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)
|
||||
@ -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
|
||||
}]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user