pipeline-opportunity/wwwroot/api/opp_attachment_dl.dspy
yumoqing 45cec8987e feat(opp): 数据参考三级整行下钻+明细详情弹窗+附件下载代理
- 汇总主题/分项条目整行点击(cursor:pointer)弹下级弹窗,撤「查看分项」按钮
- 明细每条来源写进说明文字(source_text),删除 source_button_widget 来源按钮
- 新 opp_item_detail.dspy: 爬虫平台条目全量字段+公告全文+附件列表
- 新 opp_attachment_dl.dspy: 服务端附件下载代理,归属校验防SSRF,
  降级链 download_url→/api/attachments/{id}/download→原始url,失败如实报错
- opp_data_capability: tender_attachments/normalize_attachment 容错对接
  爬虫平台附件子系统(/api/tenders/{id}/attachments,另一会话开发中,未就绪时降级提示)
- opp_common: crawler_get_raw 原始字节下载辅助; load_path 注册两个新端点
- README/prompt 同步新交互描述
2026-09-08 23:39:16 +08:00

76 lines
3.2 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# opp_attachment_dl.dspy - 附件下载代理(服务端带 token 取爬虫平台附件字节回传浏览器)
# 入参:tender_id=<条目id> + (att_id=<附件id> 或 att_url=<附件原始url>二选一)
# 安全:att_url 必须回查该条目的附件清单验证归属,前端不可传任意地址(防 SSRF);
# att_id 同样只用于在下载候选里定位,不作为取文件的路径拼接输入。
# 下载源降级链:记录自带 download_url → 约定端点 /api/attachments/{id}/download → 附件原始 url。
from urllib.parse import quote
from aiohttp import web
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
tender_id = str((params_kw or {}).get("tender_id") or "").strip()
att_id = str((params_kw or {}).get("att_id") or "").strip()
att_url = str((params_kw or {}).get("att_url") or "").strip()
if not tender_id or not (att_id or att_url):
return {"widgettype": "Message", "options": {
"title": "缺少参数", "message": "需要 tender_id + att_id/att_url"}}
dbname = get_module_dbname('pipeline-opportunity')
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import tender_attachments
from pipeline_opportunity.opp_common import crawler_get_raw, get_crawler_config
ok, atts = await tender_attachments(sor, tender_id)
await sor.sqlExe("COMMIT", {})
base, _token = await get_crawler_config(sor)
await sor.sqlExe("COMMIT", {})
if not ok:
return {"widgettype": "Message", "options": {
"title": "附件服务不可用", "message": str(atts)[:200]}}
# ── 定位附件(必须在清单内,归属校验)──
target = None
for a in atts:
if att_id and str(a.get("id") or "") == att_id:
target = a
break
if att_url and a.get("url") == att_url:
target = a
break
if target is None:
return {"widgettype": "Message", "options": {
"title": "附件不存在", "message": "该附件不在条目 %s 的附件清单中" % tender_id}}
# ── 下载候选降级链 ──
candidates = []
du = str(target.get("download_url") or "")
if du:
candidates.append(du if du.startswith("http") else base + du)
if att_id or str(target.get("id") or ""):
candidates.append(base + "/api/attachments/%s/download" % (att_id or target.get("id")))
if target.get("url"):
candidates.append(target["url"])
if not candidates:
return {"widgettype": "Message", "options": {
"title": "无可下载地址", "message": "该附件记录缺少可用地址"}}
err_msgs = []
for cand in candidates:
ok2, data, ctype, disp_name = await crawler_get_raw(sor, cand, timeout=120)
if ok2 and data:
name = disp_name or str(target.get("name") or "attachment")
headers = {"Content-Disposition":
'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (quote(name, safe=""), quote(name))}
return web.Response(body=data,
content_type=(ctype or "application/octet-stream").split(";")[0].strip() or None,
headers=headers)
err_msgs.append(str(cand)[:80] + " → " + str(disp_name)[:80])
return {"widgettype": "Message", "options": {
"title": "附件下载失败",
"message": "已尝试 %d 个下载地址均失败:%s" % (len(candidates), ";".join(err_msgs)[:300])}}