export_attachment_zip.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. """列表导出:Excel 总表 + 附件目录 ZIP(计划归档 / 充值申请等共用)。"""
  2. from __future__ import annotations
  3. import hashlib
  4. import io
  5. import json
  6. import re
  7. import zipfile
  8. from typing import Any, Callable, Iterator, Mapping, Optional, Sequence, Tuple
  9. from urllib.parse import quote, unquote, urlparse
  10. from fastapi.responses import StreamingResponse
  11. from sqlalchemy.orm import Session
  12. from app.utils.excel_export import DictLabelResolver, ExportColumn, ExportValidationError
  13. EXPORT_ZIP_ATTACH_COL_MAX = 20
  14. EXPORT_ZIP_MAX_FILES = 2000
  15. EXPORT_ZIP_MAX_BYTES = 500 * 1024 * 1024
  16. # 兼容旧常量名
  17. ARCHIVE_ZIP_ATTACH_COL_MAX = EXPORT_ZIP_ATTACH_COL_MAX
  18. ARCHIVE_ZIP_MAX_FILES = EXPORT_ZIP_MAX_FILES
  19. ARCHIVE_ZIP_MAX_BYTES = EXPORT_ZIP_MAX_BYTES
  20. def sanitize_zip_segment(raw: str) -> str:
  21. value = re.sub(r'[\\/:*?"<>|]+', "_", str(raw or "").strip())
  22. value = re.sub(r"\s+", " ", value).strip()
  23. return value or "未命名"
  24. def short_hash(text: str, *, n: int = 6) -> str:
  25. return hashlib.sha1(str(text or "").encode("utf-8")).hexdigest()[:n]
  26. def guess_ext_from_url(url: str) -> str:
  27. path = unquote(urlparse(str(url or "").split("?", 1)[0]).path)
  28. name = path.rsplit("/", 1)[-1]
  29. if "." not in name:
  30. return "bin"
  31. ext = name.rsplit(".", 1)[-1].lower()
  32. if not ext or len(ext) > 8 or "/" in ext:
  33. return "bin"
  34. return ext
  35. def build_attachment_filename(row_no: int, label: str, url: str) -> str:
  36. return (
  37. f"{int(row_no):02d}_{sanitize_zip_segment(label)}_"
  38. f"{short_hash(url)}.{guess_ext_from_url(url)}"
  39. )
  40. def first_row_no_by_entity_id(
  41. rows: Sequence[Mapping[str, Any]], *, id_field: str
  42. ) -> dict[str, int]:
  43. out: dict[str, int] = {}
  44. for row in rows:
  45. entity_id = str(row.get(id_field) or "").strip()
  46. if not entity_id or entity_id in out:
  47. continue
  48. try:
  49. out[entity_id] = int(row.get("rowNo") or 0)
  50. except (TypeError, ValueError):
  51. out[entity_id] = 0
  52. return out
  53. def build_hyperlink_formula(rel_path: str, display: str) -> str:
  54. path = str(rel_path).replace('"', '""')
  55. text = sanitize_zip_segment(display).replace('"', '""')
  56. return f'=HYPERLINK("{path}","{text}")'
  57. def parse_url_list(raw: Optional[str]) -> list[str]:
  58. if not raw:
  59. return []
  60. text = str(raw).strip()
  61. if not text:
  62. return []
  63. try:
  64. parsed = json.loads(text)
  65. if isinstance(parsed, list):
  66. return [str(x).strip() for x in parsed if str(x).strip()]
  67. except json.JSONDecodeError:
  68. pass
  69. return [p.strip() for p in text.split(",") if p.strip()]
  70. def urls_to_attachment_items(
  71. raw: Optional[str], *, label_prefix: str = "附件"
  72. ) -> list[dict[str, str]]:
  73. """将逗号/JSON URL 列表转为 {url, label} 列表。"""
  74. out: list[dict[str, str]] = []
  75. for i, url in enumerate(parse_url_list(raw), start=1):
  76. out.append({"url": url, "label": f"{label_prefix}{i}"})
  77. return out
  78. def _default_download(url: str) -> bytes:
  79. from app.services.bx.storage import download_file_bytes
  80. return download_file_bytes(url)
  81. def _attach_field(index: int) -> str:
  82. return f"attach{index}"
  83. def _plan_attachment_jobs(
  84. *,
  85. rows: Sequence[Mapping[str, Any]],
  86. attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
  87. id_field: str,
  88. attach_dir: str,
  89. ) -> Tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]]]:
  90. """预先确定附件 zip 路径与超链接(尚未下载)。
  91. 返回:
  92. entity_file_meta: entity_id -> [(zip_path, label, formula_rel), ...]
  93. download_jobs: [(zip_path, label, url), ...] 按写入顺序
  94. """
  95. row_no_by_id = first_row_no_by_entity_id(rows, id_field=id_field)
  96. entity_file_meta: dict[str, list[tuple[str, str, str]]] = {}
  97. download_jobs: list[tuple[str, str, str]] = []
  98. planned = 0
  99. for entity_id, items in (attachments_by_id or {}).items():
  100. seq = row_no_by_id.get(entity_id) or 0
  101. if seq <= 0:
  102. continue
  103. meta: list[tuple[str, str, str]] = []
  104. for item in items or []:
  105. url = str(item.get("url") or "").strip()
  106. if not url:
  107. continue
  108. planned += 1
  109. if planned > EXPORT_ZIP_MAX_FILES:
  110. raise ExportValidationError(
  111. f"附件文件数超过 {EXPORT_ZIP_MAX_FILES},请缩小筛选范围"
  112. )
  113. item_label = str(item.get("label") or "附件")
  114. filename = build_attachment_filename(seq, item_label, url)
  115. zip_path = f"{attach_dir}/{filename}"
  116. formula_rel = f"附件/{filename}"
  117. meta.append((zip_path, item_label, formula_rel))
  118. download_jobs.append((zip_path, item_label, url))
  119. if meta:
  120. entity_file_meta[entity_id] = meta
  121. return entity_file_meta, download_jobs
  122. def _build_xlsx_bytes(
  123. *,
  124. db: Optional[Session],
  125. tenant_no: str,
  126. columns: Sequence[ExportColumn],
  127. rows: Sequence[Mapping[str, Any]],
  128. entity_file_meta: Mapping[str, Sequence[tuple[str, str, str]]],
  129. id_field: str,
  130. sheet: str,
  131. ) -> bytes:
  132. max_att = 0
  133. for meta in entity_file_meta.values():
  134. max_att = max(max_att, len(meta))
  135. k = min(EXPORT_ZIP_ATTACH_COL_MAX, max_att)
  136. attach_columns = [
  137. ExportColumn(title=f"附件_{i}", field=_attach_field(i)) for i in range(1, k + 1)
  138. ]
  139. all_columns = list(columns) + attach_columns
  140. enriched_rows: list[dict[str, Any]] = []
  141. seen_first: set[str] = set()
  142. for row in rows:
  143. data = dict(row)
  144. entity_id = str(data.get(id_field) or "").strip()
  145. for i in range(1, k + 1):
  146. data[_attach_field(i)] = ""
  147. if entity_id and entity_id not in seen_first:
  148. seen_first.add(entity_id)
  149. meta = list(entity_file_meta.get(entity_id) or [])
  150. for i, (_zip_path, item_label, formula_rel) in enumerate(meta[:k], start=1):
  151. data[_attach_field(i)] = build_hyperlink_formula(formula_rel, item_label)
  152. enriched_rows.append(data)
  153. if not enriched_rows:
  154. raise ExportValidationError("没有可导出的数据")
  155. if not all_columns:
  156. raise ExportValidationError("没有可导出的列")
  157. resolver = DictLabelResolver(db, all_columns, tenant_no=tenant_no)
  158. from openpyxl import Workbook
  159. wb = Workbook()
  160. ws = wb.active
  161. ws.title = sheet
  162. ws.append([c.title for c in all_columns])
  163. for row in enriched_rows:
  164. cells = resolver.row_to_cells(row, all_columns)
  165. ws.append(cells)
  166. if sheet == "计划归档":
  167. from app.services.pl_plan_archive_export import append_archive_list_merge_sheet
  168. append_archive_list_merge_sheet(wb, all_columns, enriched_rows, resolver)
  169. xlsx_buf = io.BytesIO()
  170. wb.save(xlsx_buf)
  171. return xlsx_buf.getvalue()
  172. def build_attachment_zip_bytes(
  173. *,
  174. db: Optional[Session],
  175. tenant_no: str,
  176. columns: Sequence[ExportColumn],
  177. rows: Sequence[Mapping[str, Any]],
  178. attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
  179. stamp: str,
  180. package_label: str,
  181. id_field: str = "id",
  182. sheet_title: Optional[str] = None,
  183. xlsx_basename: Optional[str] = None,
  184. download_fn: Optional[Callable[[str], bytes]] = None,
  185. ) -> tuple[bytes, list[str]]:
  186. """打包 ZIP:xlsx + 附件/;返回 (zip_bytes, failure_messages)。"""
  187. failures: list[str] = []
  188. chunks = list(
  189. iter_attachment_zip_chunks(
  190. db=db,
  191. tenant_no=tenant_no,
  192. columns=columns,
  193. rows=rows,
  194. attachments_by_id=attachments_by_id,
  195. stamp=stamp,
  196. package_label=package_label,
  197. id_field=id_field,
  198. sheet_title=sheet_title,
  199. xlsx_basename=xlsx_basename,
  200. download_fn=download_fn,
  201. failures_out=failures,
  202. )
  203. )
  204. return b"".join(chunks), failures
  205. def iter_attachment_zip_chunks(
  206. *,
  207. db: Optional[Session],
  208. tenant_no: str,
  209. columns: Sequence[ExportColumn],
  210. rows: Sequence[Mapping[str, Any]],
  211. attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
  212. stamp: str,
  213. package_label: str,
  214. id_field: str = "id",
  215. sheet_title: Optional[str] = None,
  216. xlsx_basename: Optional[str] = None,
  217. download_fn: Optional[Callable[[str], bytes]] = None,
  218. failures_out: Optional[list[str]] = None,
  219. ) -> Iterator[bytes]:
  220. """边下载边产出 ZIP 字节,便于代理层在长时间打包时保持连接。"""
  221. download = download_fn or _default_download
  222. label = sanitize_zip_segment(package_label) or "导出"
  223. sheet = (sheet_title or label)[:31] or "导出"
  224. xlsx_name = xlsx_basename or f"(总表){label}.xlsx"
  225. root = f"{label}({stamp})"
  226. attach_dir = f"{root}/附件"
  227. entity_file_meta, download_jobs = _plan_attachment_jobs(
  228. rows=rows,
  229. attachments_by_id=attachments_by_id,
  230. id_field=id_field,
  231. attach_dir=attach_dir,
  232. )
  233. xlsx_bytes = _build_xlsx_bytes(
  234. db=db,
  235. tenant_no=tenant_no,
  236. columns=columns,
  237. rows=rows,
  238. entity_file_meta=entity_file_meta,
  239. id_field=id_field,
  240. sheet=sheet,
  241. )
  242. failures: list[str] = []
  243. total_bytes = 0
  244. file_count = 0
  245. buf = io.BytesIO()
  246. sent = 0
  247. def flush() -> bytes:
  248. nonlocal sent
  249. data = buf.getvalue()
  250. if len(data) <= sent:
  251. return b""
  252. chunk = data[sent:]
  253. sent = len(data)
  254. return chunk
  255. with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  256. zf.writestr(f"{root}/{xlsx_name}", xlsx_bytes)
  257. chunk = flush()
  258. if chunk:
  259. yield chunk
  260. for zip_path, item_label, url in download_jobs:
  261. try:
  262. content = download(url)
  263. except Exception as exc: # noqa: BLE001 — 单文件失败不阻断整包
  264. failures.append(f"{item_label}: {url} ({exc})")
  265. continue
  266. file_count += 1
  267. total_bytes += len(content)
  268. if file_count > EXPORT_ZIP_MAX_FILES:
  269. raise ExportValidationError(
  270. f"附件文件数超过 {EXPORT_ZIP_MAX_FILES},请缩小筛选范围"
  271. )
  272. if total_bytes > EXPORT_ZIP_MAX_BYTES:
  273. raise ExportValidationError("附件总体积过大,请缩小筛选范围后重试")
  274. zf.writestr(zip_path, content)
  275. chunk = flush()
  276. if chunk:
  277. yield chunk
  278. if failures:
  279. note = "以下附件下载失败,已跳过:\n" + "\n".join(failures)
  280. zf.writestr(f"{root}/导出说明.txt", note.encode("utf-8"))
  281. chunk = flush()
  282. if chunk:
  283. yield chunk
  284. # ZipFile 关闭后写入中央目录
  285. chunk = flush()
  286. if chunk:
  287. yield chunk
  288. if failures_out is not None:
  289. failures_out.extend(failures)
  290. def build_attachment_zip_streaming_response(
  291. *,
  292. db: Session,
  293. tenant_no: str,
  294. columns: Sequence[ExportColumn],
  295. rows: Sequence[Mapping[str, Any]],
  296. attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
  297. stamp: str,
  298. package_label: str,
  299. id_field: str = "id",
  300. sheet_title: Optional[str] = None,
  301. xlsx_basename: Optional[str] = None,
  302. download_fn: Optional[Callable[[str], bytes]] = None,
  303. ) -> StreamingResponse:
  304. """立即返回 StreamingResponse:先发响应头,再边下附件边推送 ZIP 字节。"""
  305. label = sanitize_zip_segment(package_label) or "导出"
  306. filename = f"{label}({stamp}).zip"
  307. encoded = quote(filename)
  308. disposition = f"attachment; filename={encoded}; filename*=UTF-8''{encoded}"
  309. def generate() -> Iterator[bytes]:
  310. yield from iter_attachment_zip_chunks(
  311. db=db,
  312. tenant_no=tenant_no,
  313. columns=columns,
  314. rows=rows,
  315. attachments_by_id=attachments_by_id,
  316. stamp=stamp,
  317. package_label=package_label,
  318. id_field=id_field,
  319. sheet_title=sheet_title,
  320. xlsx_basename=xlsx_basename,
  321. download_fn=download_fn,
  322. )
  323. return StreamingResponse(
  324. generate(),
  325. media_type="application/zip",
  326. headers={
  327. "Content-Disposition": disposition,
  328. # 避免中间代理整包缓冲导致长时间无字节
  329. "X-Accel-Buffering": "no",
  330. },
  331. )