From 36c4132b02cd1428e07c69850a966cc864ca90c1 Mon Sep 17 00:00:00 2001 From: ymq Date: Wed, 2 Sep 2026 11:46:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=85=E5=8A=9E=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=BB=99=E6=89=93=E5=BC=80/=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E9=93=BE=E6=8E=A5=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E?= =?UTF-8?q?project=5Ffile.dspy=E6=8C=89=E9=A1=B9=E7=9B=AEID=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=96=87=E4=BB=B6(=E8=B7=AF=E5=BE=84=E7=A9=BF?= =?UTF-8?q?=E8=B6=8A+=E6=9C=BA=E6=9E=84=E9=9A=94=E7=A6=BB=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C),=E5=BE=85=E5=8A=9E=E8=AF=A6=E6=83=85=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=8C=BA=E6=B8=B2=E6=9F=93=E9=93=BE=E6=8E=A5=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wwwroot/api/project_file.dspy | 64 +++++++++++++++++++++++++++++++++++ wwwroot/api/todo_detail.dspy | 38 +++++++++++++++++++-- 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 wwwroot/api/project_file.dspy diff --git a/wwwroot/api/project_file.dspy b/wwwroot/api/project_file.dspy new file mode 100644 index 0000000..1996247 --- /dev/null +++ b/wwwroot/api/project_file.dspy @@ -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) diff --git a/wwwroot/api/todo_detail.dspy b/wwwroot/api/todo_detail.dspy index f05872a..c7ca790 100644 --- a/wwwroot/api/todo_detail.dspy +++ b/wwwroot/api/todo_detail.dspy @@ -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 }] }