# 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])}}