| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- import io
- import os
- import logging
- from contextlib import asynccontextmanager
- import openpyxl
- import pyarrow as pa
- import pyarrow.csv as pa_csv
- import requests
- from fastapi import FastAPI, File, Form, HTTPException, UploadFile
- from fastapi.middleware.cors import CORSMiddleware
- from fastapi.responses import StreamingResponse
- from pyiceberg.catalog.rest import RestCatalog
- from pyiceberg.exceptions import NoSuchTableError
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
- logger = logging.getLogger(__name__)
- # ---------- Patch pyiceberg fsspec S3 factory for Aliyun OSS compatibility ----------
- # Aliyun OSS rejects aws-chunked encoding with UNSIGNED-PAYLOAD.
- # botocore must use payload_signing_enabled=True so that chunked uploads
- # send STREAMING-AWS4-HMAC-SHA256-PAYLOAD instead.
- import pyiceberg.io.fsspec as _fsspec_mod
- _orig_s3_factory = _fsspec_mod._s3
- def _oss_s3_factory(properties):
- """Wrap the original _s3 factory to inject payload_signing_enabled into botocore Config."""
- from s3fs import S3FileSystem
- from pyiceberg.io import (
- S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_SESSION_TOKEN,
- S3_REGION, S3_FORCE_VIRTUAL_ADDRESSING, S3_PROXY_URI, S3_CONNECT_TIMEOUT,
- S3_REQUEST_TIMEOUT, S3_ANONYMOUS, S3_PROFILE_NAME,
- AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_PROFILE_NAME,
- )
- from pyiceberg.utils.properties import get_first_property_value, strtobool
- client_kwargs = {
- "endpoint_url": properties.get(S3_ENDPOINT),
- "aws_access_key_id": get_first_property_value(properties, S3_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID),
- "aws_secret_access_key": get_first_property_value(properties, S3_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY),
- "aws_session_token": get_first_property_value(properties, S3_SESSION_TOKEN, AWS_SESSION_TOKEN),
- "region_name": get_first_property_value(properties, S3_REGION, AWS_REGION),
- }
- config_kwargs = {}
- if proxy_uri := properties.get(S3_PROXY_URI):
- config_kwargs["proxies"] = {"http": proxy_uri, "https": proxy_uri}
- if connect_timeout := properties.get(S3_CONNECT_TIMEOUT):
- config_kwargs["connect_timeout"] = float(connect_timeout)
- if request_timeout := properties.get(S3_REQUEST_TIMEOUT):
- config_kwargs["read_timeout"] = float(request_timeout)
- # OSS fix: virtual addressing + payload signing (avoids aws-chunked + UNSIGNED-PAYLOAD)
- config_kwargs["s3"] = {
- "addressing_style": "virtual",
- "payload_signing_enabled": True,
- }
- # botocore >= 1.35 adds flexible checksums that trigger aws-chunked encoding;
- # Aliyun OSS does not support this — disable automatic checksum calculation.
- config_kwargs["request_checksum_calculation"] = "when_required"
- config_kwargs["response_checksum_validation"] = "when_required"
- anon = strtobool(properties.get(S3_ANONYMOUS, "false")) if properties.get(S3_ANONYMOUS) else False
- s3_fs_kwargs = {"anon": anon, "client_kwargs": client_kwargs, "config_kwargs": config_kwargs}
- if profile_name := get_first_property_value(properties, S3_PROFILE_NAME, AWS_PROFILE_NAME):
- s3_fs_kwargs["profile"] = profile_name
- return S3FileSystem(**s3_fs_kwargs)
- _fsspec_mod.SCHEME_TO_FS["s3"] = _oss_s3_factory
- _fsspec_mod.SCHEME_TO_FS["s3a"] = _oss_s3_factory
- _fsspec_mod.SCHEME_TO_FS["s3n"] = _oss_s3_factory
- # ---------- Config (fetched from Java backend at startup) ----------
- BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8080")
- BACKEND_INTERNAL_HEADER = os.getenv("BACKEND_INTERNAL_HEADER", "X-Internal-Token")
- BACKEND_INTERNAL_TOKEN = os.getenv("BACKEND_INTERNAL_TOKEN", "").strip()
- CFG: dict[str, str] = {}
- def _fetch_config() -> None:
- """Pull config from Spring Boot backend (application.yml is the single source of truth)."""
- global CFG
- headers = {}
- if BACKEND_INTERNAL_TOKEN:
- headers[BACKEND_INTERNAL_HEADER] = BACKEND_INTERNAL_TOKEN
- resp = requests.get(
- f"{BACKEND_URL}/api/data/internal/iceberg-config",
- headers=headers or None,
- timeout=10,
- )
- resp.raise_for_status()
- CFG = resp.json()
- logger.info("Config loaded from backend: polaris_host=%s, catalog=%s, bucket=%s",
- CFG.get("polaris_host"), CFG.get("polaris_catalog"), CFG.get("oss_bucket"))
- def get_catalog() -> RestCatalog:
- if not CFG:
- raise RuntimeError("Config not loaded — is the Java backend running?")
- props = {
- "uri": f"{CFG['polaris_host']}/api/catalog",
- "credential": f"{CFG['polaris_client_id']}:{CFG['polaris_client_secret']}",
- "warehouse": CFG["polaris_catalog"],
- "header.Polaris-Realm": CFG["polaris_realm"],
- "scope": "PRINCIPAL_ROLE:ALL",
- "s3.region": CFG["oss_region"],
- # Use fsspec/s3fs (botocore) instead of PyArrow's native S3 (AWS C++ SDK).
- # PyArrow sends aws-chunked encoding with UNSIGNED-PAYLOAD which Aliyun OSS rejects.
- "py-io-impl": "pyiceberg.io.fsspec.FsspecFileIO",
- }
- endpoint = CFG.get("oss_endpoint", "")
- if endpoint:
- props["s3.endpoint"] = endpoint
- props["s3.force-virtual-addressing"] = "true"
- if CFG.get("oss_access_key_id"):
- props["s3.access-key-id"] = CFG["oss_access_key_id"]
- if CFG.get("oss_access_key_secret"):
- props["s3.secret-access-key"] = CFG["oss_access_key_secret"]
- catalog = RestCatalog(name="polaris", **props)
- catalog._session.headers.pop("X-Iceberg-Access-Delegation", None)
- return catalog
- def _ensure_namespace(catalog: RestCatalog, database: str) -> None:
- bucket = CFG.get("oss_bucket", "")
- catalog_name = CFG.get("polaris_catalog", "")
- expected_location = f"s3://{bucket}/{catalog_name}/{database}" if bucket else ""
- try:
- ns_props = {"location": expected_location} if expected_location else {}
- catalog.create_namespace(database, ns_props)
- except Exception:
- # Namespace already exists — make sure its location points to the correct bucket
- if expected_location:
- try:
- catalog.update_namespace_properties(database, updates={"location": expected_location})
- except Exception:
- pass
- def _write(catalog: RestCatalog, database: str, table: str, arrow_table: pa.Table, mode: str) -> int:
- identifier = f"{database}.{table}"
- _ensure_namespace(catalog, database)
- try:
- tbl = catalog.load_table(identifier)
- if mode == "overwrite":
- tbl.overwrite(arrow_table)
- else:
- tbl.append(arrow_table)
- except NoSuchTableError:
- tbl = catalog.create_table(identifier=identifier, schema=arrow_table.schema)
- tbl.append(arrow_table)
- return len(arrow_table)
- # ---------- Startup: fetch config & ensure Polaris grants ----------
- def _get_management_token() -> str:
- resp = requests.post(
- f"{CFG['polaris_host']}/api/catalog/v1/oauth/tokens",
- headers={"Polaris-Realm": CFG["polaris_realm"]},
- data={
- "grant_type": "client_credentials",
- "client_id": CFG["polaris_client_id"],
- "client_secret": CFG["polaris_client_secret"],
- "scope": "PRINCIPAL_ROLE:ALL",
- },
- timeout=10,
- )
- resp.raise_for_status()
- return resp.json()["access_token"]
- def _ensure_catalog_grants(headers: dict) -> None:
- catalog_name = CFG["polaris_catalog"]
- url = f"{CFG['polaris_host']}/api/management/v1/catalogs/{catalog_name}/catalog-roles/catalog_admin/grants"
- resp = requests.put(url, headers=headers, json={"type": "catalog", "privilege": "CATALOG_MANAGE_CONTENT"}, timeout=10)
- already_exists = resp.status_code == 409 or (resp.status_code == 500 and "duplicate key" in resp.text)
- if resp.status_code == 201:
- logger.info("Granted CATALOG_MANAGE_CONTENT to catalog_admin on %s", catalog_name)
- elif already_exists:
- logger.info("CATALOG_MANAGE_CONTENT already granted on %s", catalog_name)
- else:
- logger.warning("Grant response %s: %s", resp.status_code, resp.text)
- def _ensure_catalog_storage_credentials(headers: dict) -> None:
- ak = CFG.get("oss_access_key_id", "")
- sk = CFG.get("oss_access_key_secret", "")
- if not ak or not sk:
- logger.warning("OSS credentials not configured — skipping credential storage")
- return
- catalog_name = CFG["polaris_catalog"]
- bucket = CFG.get("oss_bucket", "")
- base_location = f"s3://{bucket}/{catalog_name}" if bucket else ""
- get_resp = requests.get(
- f"{CFG['polaris_host']}/api/management/v1/catalogs/{catalog_name}",
- headers=headers, timeout=10,
- )
- get_resp.raise_for_status()
- catalog_info = get_resp.json()
- entity_version = catalog_info["entityVersion"]
- storage = catalog_info.get("storageConfigInfo", {})
- current_props = catalog_info.get("properties", {})
- if (storage.get("accessKeyId") == ak
- and (not base_location or current_props.get("default-base-location") == base_location)):
- logger.info("OSS credentials already stored in Polaris catalog %s", catalog_name)
- return
- updated_props = {**current_props}
- if base_location:
- updated_props["default-base-location"] = base_location
- # Ensure DROP with purge can clean up underlying OSS objects for this catalog.
- updated_props["polaris.config.drop-with-purge.enabled"] = "true"
- update_body = {
- "currentEntityVersion": entity_version,
- "properties": updated_props,
- "storageConfigInfo": {
- "storageType": storage.get("storageType", "S3"),
- "allowedLocations": [f"s3://{bucket}/"] if bucket else storage.get("allowedLocations", []),
- "region": storage.get("region", CFG.get("oss_region", "")),
- "endpoint": storage.get("endpoint", CFG.get("oss_endpoint", "")),
- "pathStyleAccess": storage.get("pathStyleAccess", False),
- "stsUnavailable": True,
- "accessKeyId": ak,
- "secretAccessKey": sk,
- },
- }
- put_resp = requests.put(
- f"{CFG['polaris_host']}/api/management/v1/catalogs/{catalog_name}",
- headers=headers, json=update_body, timeout=10,
- )
- if put_resp.status_code == 200:
- logger.info("Stored OSS credentials in Polaris catalog %s", catalog_name)
- else:
- logger.warning("Catalog credential update %s: %s", put_resp.status_code, put_resp.text)
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- import asyncio
- # Config fetch must succeed — retry until Java backend is reachable
- for attempt in range(30):
- try:
- _fetch_config()
- break
- except Exception as e:
- logger.warning("Config fetch attempt %d failed: %s — retrying in 2s", attempt + 1, e)
- await asyncio.sleep(2)
- else:
- logger.error("Could not fetch config from backend after 30 attempts")
- if CFG:
- try:
- token = _get_management_token()
- headers = {
- "Authorization": f"Bearer {token}",
- "Polaris-Realm": CFG["polaris_realm"],
- "Content-Type": "application/json",
- }
- _ensure_catalog_grants(headers)
- _ensure_catalog_storage_credentials(headers)
- except Exception as e:
- logger.warning("Polaris setup failed (will retry on next restart): %s", e)
- yield
- # ---------- App ----------
- app = FastAPI(title="Iceberg Data Service", lifespan=lifespan)
- app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_methods=["*"],
- allow_headers=["*"],
- )
- @app.get("/health")
- def health():
- return {"status": "ok"}
- # ---------- Excel import endpoint ----------
- @app.post("/import/excel")
- async def import_excel(
- database: str,
- file: UploadFile = File(...),
- mode: str = Form(default="append"),
- ):
- try:
- content = await file.read()
- wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
- catalog = get_catalog()
- results = []
- seen_names: set[str] = set()
- for sheet_name in wb.sheetnames:
- # 检测同一 Excel 内重复 Sheet 名(Excel 本身禁止,但做防御性检查)
- name_key = sheet_name.strip().lower()
- if name_key in seen_names:
- results.append({"table": sheet_name, "error": f"Excel 中存在重复 Sheet 名「{sheet_name}」"})
- continue
- seen_names.add(name_key)
- ws = wb[sheet_name]
- rows_iter = ws.iter_rows(values_only=True)
- try:
- header_row = next(rows_iter)
- except StopIteration:
- results.append({"table": sheet_name, "skipped": True, "reason": "空 sheet"})
- continue
- headers = [str(h) if h is not None else f"col_{i}" for i, h in enumerate(header_row)]
- if not any(h for h in headers):
- results.append({"table": sheet_name, "skipped": True, "reason": "空表头"})
- continue
- # 检测目标库中是否已存在同名表(overwrite 模式允许覆盖)
- if mode != "overwrite":
- try:
- catalog.load_table(f"{database}.{sheet_name}")
- results.append({"table": sheet_name, "error": f"表「{sheet_name}」已存在,请先删除后再导入"})
- continue
- except NoSuchTableError:
- pass
- data_rows = list(rows_iter)
- if not data_rows:
- arrays = [pa.array([], type=pa.string()) for _ in headers]
- else:
- cols = [[row[i] if i < len(row) else None for row in data_rows] for i in range(len(headers))]
- arrays = [pa.array(col) for col in cols]
- arrow_table = pa.table(dict(zip(headers, arrays)))
- try:
- row_count = _write(catalog, database, sheet_name, arrow_table, mode)
- results.append({"table": sheet_name, "rows": row_count, "columns": len(headers)})
- except Exception as e:
- results.append({"table": sheet_name, "columns": len(headers), "error": str(e)})
- wb.close()
- return {"success": True, "tables": results}
- except Exception as e:
- logger.error("Excel import error", exc_info=True)
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/import/csv")
- async def import_csv(
- database: str,
- table: str,
- file: UploadFile = File(...),
- mode: str = Form(default="append"),
- ):
- try:
- content = await file.read()
- arrow_table = pa_csv.read_csv(io.BytesIO(content))
- rows = _write(get_catalog(), database, table, arrow_table, mode)
- return {"success": True, "rows": rows, "columns": len(arrow_table.schema)}
- except Exception as e:
- logger.error("CSV import error", exc_info=True)
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/export/csv")
- async def export_csv(database: str, table: str):
- try:
- catalog = get_catalog()
- tbl = catalog.load_table(f"{database}.{table}")
- arrow_table = tbl.scan().to_arrow()
- buf = io.BytesIO()
- # Add UTF-8 BOM to make Excel happy with Chinese characters
- buf.write(b"\xef\xbb\xbf")
- pa_csv.write_csv(arrow_table, buf)
- buf.seek(0)
- filename = f"{database}_{table}.csv"
- return StreamingResponse(
- buf,
- media_type="text/csv; charset=utf-8",
- headers={"Content-Disposition": f'attachment; filename="{filename}"'},
- )
- except Exception as e:
- logger.error("CSV export error", exc_info=True)
- raise HTTPException(status_code=500, detail=str(e))
- # ---------- LLM Function Calling 查询接口 ----------
- import pyarrow.compute as pc
- from typing import Optional
- def _safe_json(val):
- """将 PyArrow .as_py() 的值转换为 JSON 可序列化的 Python 类型。"""
- if val is None:
- return None
- if isinstance(val, (int, float, bool, str)):
- return val
- # date / datetime / Decimal 等 → str
- return str(val)
- def _to_column_flat(arrow_table: pa.Table, column: str) -> pa.Array:
- """取出指定列并合并 chunk,返回 pa.Array。"""
- col = arrow_table.column(column)
- return col.combine_chunks() if isinstance(col, pa.ChunkedArray) else col
- @app.get("/preview")
- async def preview(database: str, table: str, limit: int = 5):
- """
- query_sample_data:返回表的前 N 行样本数据。
- 响应:{"table": str, "columns": [...], "rows": [[...], ...], "total_returned": int}
- """
- limit = min(max(limit, 1), 500)
- try:
- tbl = get_catalog().load_table(f"{database}.{table}")
- arrow_table = tbl.scan(limit=limit).to_arrow()
- columns = arrow_table.schema.names
- rows = [
- [_safe_json(arrow_table.column(c)[i].as_py()) for c in columns]
- for i in range(len(arrow_table))
- ]
- return {"table": table, "columns": columns, "rows": rows, "total_returned": len(arrow_table)}
- except NoSuchTableError:
- raise HTTPException(status_code=404, detail=f"表 {database}.{table} 不存在")
- except Exception as e:
- logger.error("preview error", exc_info=True)
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/distinct")
- async def distinct_values(database: str, table: str, column: str, limit: int = 20):
- """
- query_distinct_values:返回指定列的不同值及出现次数(按频次降序)。
- 响应:{"column": str, "total_distinct": int, "values": [{"val": ..., "count": int}, ...]}
- """
- limit = min(max(limit, 1), 500)
- try:
- tbl = get_catalog().load_table(f"{database}.{table}")
- arrow_table = tbl.scan(selected_fields=(column,)).to_arrow()
- col_flat = _to_column_flat(arrow_table, column)
- total_distinct = pc.count_distinct(col_flat, mode="only_valid").as_py()
- # value_counts() → StructArray {values, counts};建表后排序
- vc = col_flat.value_counts()
- vc_table = pa.table({
- "val": vc.field("values"),
- "cnt": vc.field("counts"),
- })
- sorted_vc = vc_table.sort_by([("cnt", "descending")]).slice(0, limit)
- values = [
- {"val": _safe_json(sorted_vc.column("val")[i].as_py()),
- "count": sorted_vc.column("cnt")[i].as_py()}
- for i in range(len(sorted_vc))
- ]
- return {"column": column, "total_distinct": total_distinct, "values": values}
- except NoSuchTableError:
- raise HTTPException(status_code=404, detail=f"表 {database}.{table} 不存在")
- except Exception as e:
- logger.error("distinct error", exc_info=True)
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/statistics")
- async def statistics(database: str, table: str, column: Optional[str] = None):
- """
- query_statistics:返回表的行数;若指定列则额外返回 min/max/distinct_count/null_count。
- 响应:{"table": str, "total_rows": int, "column"?: str, "min"?: ..., "max"?: ...,
- "distinct_count"?: int, "null_count"?: int}
- """
- try:
- tbl = get_catalog().load_table(f"{database}.{table}")
- result: dict = {"table": table}
- if column:
- arrow_table = tbl.scan(selected_fields=(column,)).to_arrow()
- result["total_rows"] = len(arrow_table)
- result["column"] = column
- col_flat = _to_column_flat(arrow_table, column)
- try:
- result["min"] = _safe_json(pc.min(col_flat).as_py())
- result["max"] = _safe_json(pc.max(col_flat).as_py())
- result["distinct_count"] = pc.count_distinct(col_flat, mode="only_valid").as_py()
- result["null_count"] = col_flat.null_count
- except Exception as stat_err:
- result["stats_error"] = str(stat_err)
- else:
- # 仅统计行数:用 metadata 统计,避免全量扫描
- result["total_rows"] = tbl.scan().count()
- return result
- except NoSuchTableError:
- raise HTTPException(status_code=404, detail=f"表 {database}.{table} 不存在")
- except Exception as e:
- logger.error("statistics error", exc_info=True)
- raise HTTPException(status_code=500, detail=str(e))
|