| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- """列表导出:Excel 总表 + 附件目录 ZIP(计划归档 / 充值申请等共用)。"""
- from __future__ import annotations
- import hashlib
- import io
- import json
- import re
- import zipfile
- from typing import Any, Callable, Iterator, Mapping, Optional, Sequence, Tuple
- from urllib.parse import quote, unquote, urlparse
- from fastapi.responses import StreamingResponse
- from sqlalchemy.orm import Session
- from app.utils.excel_export import DictLabelResolver, ExportColumn, ExportValidationError
- EXPORT_ZIP_ATTACH_COL_MAX = 20
- EXPORT_ZIP_MAX_FILES = 2000
- EXPORT_ZIP_MAX_BYTES = 500 * 1024 * 1024
- # 兼容旧常量名
- ARCHIVE_ZIP_ATTACH_COL_MAX = EXPORT_ZIP_ATTACH_COL_MAX
- ARCHIVE_ZIP_MAX_FILES = EXPORT_ZIP_MAX_FILES
- ARCHIVE_ZIP_MAX_BYTES = EXPORT_ZIP_MAX_BYTES
- def sanitize_zip_segment(raw: str) -> str:
- value = re.sub(r'[\\/:*?"<>|]+', "_", str(raw or "").strip())
- value = re.sub(r"\s+", " ", value).strip()
- return value or "未命名"
- def short_hash(text: str, *, n: int = 6) -> str:
- return hashlib.sha1(str(text or "").encode("utf-8")).hexdigest()[:n]
- def guess_ext_from_url(url: str) -> str:
- path = unquote(urlparse(str(url or "").split("?", 1)[0]).path)
- name = path.rsplit("/", 1)[-1]
- if "." not in name:
- return "bin"
- ext = name.rsplit(".", 1)[-1].lower()
- if not ext or len(ext) > 8 or "/" in ext:
- return "bin"
- return ext
- def build_attachment_filename(row_no: int, label: str, url: str) -> str:
- return (
- f"{int(row_no):02d}_{sanitize_zip_segment(label)}_"
- f"{short_hash(url)}.{guess_ext_from_url(url)}"
- )
- def first_row_no_by_entity_id(
- rows: Sequence[Mapping[str, Any]], *, id_field: str
- ) -> dict[str, int]:
- out: dict[str, int] = {}
- for row in rows:
- entity_id = str(row.get(id_field) or "").strip()
- if not entity_id or entity_id in out:
- continue
- try:
- out[entity_id] = int(row.get("rowNo") or 0)
- except (TypeError, ValueError):
- out[entity_id] = 0
- return out
- def build_hyperlink_formula(rel_path: str, display: str) -> str:
- path = str(rel_path).replace('"', '""')
- text = sanitize_zip_segment(display).replace('"', '""')
- return f'=HYPERLINK("{path}","{text}")'
- def parse_url_list(raw: Optional[str]) -> list[str]:
- if not raw:
- return []
- text = str(raw).strip()
- if not text:
- return []
- try:
- parsed = json.loads(text)
- if isinstance(parsed, list):
- return [str(x).strip() for x in parsed if str(x).strip()]
- except json.JSONDecodeError:
- pass
- return [p.strip() for p in text.split(",") if p.strip()]
- def urls_to_attachment_items(
- raw: Optional[str], *, label_prefix: str = "附件"
- ) -> list[dict[str, str]]:
- """将逗号/JSON URL 列表转为 {url, label} 列表。"""
- out: list[dict[str, str]] = []
- for i, url in enumerate(parse_url_list(raw), start=1):
- out.append({"url": url, "label": f"{label_prefix}{i}"})
- return out
- def _default_download(url: str) -> bytes:
- from app.services.bx.storage import download_file_bytes
- return download_file_bytes(url)
- def _attach_field(index: int) -> str:
- return f"attach{index}"
- def _plan_attachment_jobs(
- *,
- rows: Sequence[Mapping[str, Any]],
- attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
- id_field: str,
- attach_dir: str,
- ) -> Tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]]]:
- """预先确定附件 zip 路径与超链接(尚未下载)。
- 返回:
- entity_file_meta: entity_id -> [(zip_path, label, formula_rel), ...]
- download_jobs: [(zip_path, label, url), ...] 按写入顺序
- """
- row_no_by_id = first_row_no_by_entity_id(rows, id_field=id_field)
- entity_file_meta: dict[str, list[tuple[str, str, str]]] = {}
- download_jobs: list[tuple[str, str, str]] = []
- planned = 0
- for entity_id, items in (attachments_by_id or {}).items():
- seq = row_no_by_id.get(entity_id) or 0
- if seq <= 0:
- continue
- meta: list[tuple[str, str, str]] = []
- for item in items or []:
- url = str(item.get("url") or "").strip()
- if not url:
- continue
- planned += 1
- if planned > EXPORT_ZIP_MAX_FILES:
- raise ExportValidationError(
- f"附件文件数超过 {EXPORT_ZIP_MAX_FILES},请缩小筛选范围"
- )
- item_label = str(item.get("label") or "附件")
- filename = build_attachment_filename(seq, item_label, url)
- zip_path = f"{attach_dir}/{filename}"
- formula_rel = f"附件/{filename}"
- meta.append((zip_path, item_label, formula_rel))
- download_jobs.append((zip_path, item_label, url))
- if meta:
- entity_file_meta[entity_id] = meta
- return entity_file_meta, download_jobs
- def _build_xlsx_bytes(
- *,
- db: Optional[Session],
- tenant_no: str,
- columns: Sequence[ExportColumn],
- rows: Sequence[Mapping[str, Any]],
- entity_file_meta: Mapping[str, Sequence[tuple[str, str, str]]],
- id_field: str,
- sheet: str,
- ) -> bytes:
- max_att = 0
- for meta in entity_file_meta.values():
- max_att = max(max_att, len(meta))
- k = min(EXPORT_ZIP_ATTACH_COL_MAX, max_att)
- attach_columns = [
- ExportColumn(title=f"附件_{i}", field=_attach_field(i)) for i in range(1, k + 1)
- ]
- all_columns = list(columns) + attach_columns
- enriched_rows: list[dict[str, Any]] = []
- seen_first: set[str] = set()
- for row in rows:
- data = dict(row)
- entity_id = str(data.get(id_field) or "").strip()
- for i in range(1, k + 1):
- data[_attach_field(i)] = ""
- if entity_id and entity_id not in seen_first:
- seen_first.add(entity_id)
- meta = list(entity_file_meta.get(entity_id) or [])
- for i, (_zip_path, item_label, formula_rel) in enumerate(meta[:k], start=1):
- data[_attach_field(i)] = build_hyperlink_formula(formula_rel, item_label)
- enriched_rows.append(data)
- if not enriched_rows:
- raise ExportValidationError("没有可导出的数据")
- if not all_columns:
- raise ExportValidationError("没有可导出的列")
- resolver = DictLabelResolver(db, all_columns, tenant_no=tenant_no)
- from openpyxl import Workbook
- wb = Workbook()
- ws = wb.active
- ws.title = sheet
- ws.append([c.title for c in all_columns])
- for row in enriched_rows:
- cells = resolver.row_to_cells(row, all_columns)
- ws.append(cells)
- if sheet == "计划归档":
- from app.services.pl_plan_archive_export import append_archive_list_merge_sheet
- append_archive_list_merge_sheet(wb, all_columns, enriched_rows, resolver)
- xlsx_buf = io.BytesIO()
- wb.save(xlsx_buf)
- return xlsx_buf.getvalue()
- def build_attachment_zip_bytes(
- *,
- db: Optional[Session],
- tenant_no: str,
- columns: Sequence[ExportColumn],
- rows: Sequence[Mapping[str, Any]],
- attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
- stamp: str,
- package_label: str,
- id_field: str = "id",
- sheet_title: Optional[str] = None,
- xlsx_basename: Optional[str] = None,
- download_fn: Optional[Callable[[str], bytes]] = None,
- ) -> tuple[bytes, list[str]]:
- """打包 ZIP:xlsx + 附件/;返回 (zip_bytes, failure_messages)。"""
- failures: list[str] = []
- chunks = list(
- iter_attachment_zip_chunks(
- db=db,
- tenant_no=tenant_no,
- columns=columns,
- rows=rows,
- attachments_by_id=attachments_by_id,
- stamp=stamp,
- package_label=package_label,
- id_field=id_field,
- sheet_title=sheet_title,
- xlsx_basename=xlsx_basename,
- download_fn=download_fn,
- failures_out=failures,
- )
- )
- return b"".join(chunks), failures
- def iter_attachment_zip_chunks(
- *,
- db: Optional[Session],
- tenant_no: str,
- columns: Sequence[ExportColumn],
- rows: Sequence[Mapping[str, Any]],
- attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
- stamp: str,
- package_label: str,
- id_field: str = "id",
- sheet_title: Optional[str] = None,
- xlsx_basename: Optional[str] = None,
- download_fn: Optional[Callable[[str], bytes]] = None,
- failures_out: Optional[list[str]] = None,
- ) -> Iterator[bytes]:
- """边下载边产出 ZIP 字节,便于代理层在长时间打包时保持连接。"""
- download = download_fn or _default_download
- label = sanitize_zip_segment(package_label) or "导出"
- sheet = (sheet_title or label)[:31] or "导出"
- xlsx_name = xlsx_basename or f"(总表){label}.xlsx"
- root = f"{label}({stamp})"
- attach_dir = f"{root}/附件"
- entity_file_meta, download_jobs = _plan_attachment_jobs(
- rows=rows,
- attachments_by_id=attachments_by_id,
- id_field=id_field,
- attach_dir=attach_dir,
- )
- xlsx_bytes = _build_xlsx_bytes(
- db=db,
- tenant_no=tenant_no,
- columns=columns,
- rows=rows,
- entity_file_meta=entity_file_meta,
- id_field=id_field,
- sheet=sheet,
- )
- failures: list[str] = []
- total_bytes = 0
- file_count = 0
- buf = io.BytesIO()
- sent = 0
- def flush() -> bytes:
- nonlocal sent
- data = buf.getvalue()
- if len(data) <= sent:
- return b""
- chunk = data[sent:]
- sent = len(data)
- return chunk
- with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
- zf.writestr(f"{root}/{xlsx_name}", xlsx_bytes)
- chunk = flush()
- if chunk:
- yield chunk
- for zip_path, item_label, url in download_jobs:
- try:
- content = download(url)
- except Exception as exc: # noqa: BLE001 — 单文件失败不阻断整包
- failures.append(f"{item_label}: {url} ({exc})")
- continue
- file_count += 1
- total_bytes += len(content)
- if file_count > EXPORT_ZIP_MAX_FILES:
- raise ExportValidationError(
- f"附件文件数超过 {EXPORT_ZIP_MAX_FILES},请缩小筛选范围"
- )
- if total_bytes > EXPORT_ZIP_MAX_BYTES:
- raise ExportValidationError("附件总体积过大,请缩小筛选范围后重试")
- zf.writestr(zip_path, content)
- chunk = flush()
- if chunk:
- yield chunk
- if failures:
- note = "以下附件下载失败,已跳过:\n" + "\n".join(failures)
- zf.writestr(f"{root}/导出说明.txt", note.encode("utf-8"))
- chunk = flush()
- if chunk:
- yield chunk
- # ZipFile 关闭后写入中央目录
- chunk = flush()
- if chunk:
- yield chunk
- if failures_out is not None:
- failures_out.extend(failures)
- def build_attachment_zip_streaming_response(
- *,
- db: Session,
- tenant_no: str,
- columns: Sequence[ExportColumn],
- rows: Sequence[Mapping[str, Any]],
- attachments_by_id: Mapping[str, Sequence[Mapping[str, str]]],
- stamp: str,
- package_label: str,
- id_field: str = "id",
- sheet_title: Optional[str] = None,
- xlsx_basename: Optional[str] = None,
- download_fn: Optional[Callable[[str], bytes]] = None,
- ) -> StreamingResponse:
- """立即返回 StreamingResponse:先发响应头,再边下附件边推送 ZIP 字节。"""
- label = sanitize_zip_segment(package_label) or "导出"
- filename = f"{label}({stamp}).zip"
- encoded = quote(filename)
- disposition = f"attachment; filename={encoded}; filename*=UTF-8''{encoded}"
- def generate() -> Iterator[bytes]:
- yield from iter_attachment_zip_chunks(
- db=db,
- tenant_no=tenant_no,
- columns=columns,
- rows=rows,
- attachments_by_id=attachments_by_id,
- stamp=stamp,
- package_label=package_label,
- id_field=id_field,
- sheet_title=sheet_title,
- xlsx_basename=xlsx_basename,
- download_fn=download_fn,
- )
- return StreamingResponse(
- generate(),
- media_type="application/zip",
- headers={
- "Content-Disposition": disposition,
- # 避免中间代理整包缓冲导致长时间无字节
- "X-Accel-Buffering": "no",
- },
- )
|