Files

634 lines
21 KiB
Python
Raw Permalink Normal View History

2026-03-18 14:40:48 +08:00
#!/usr/bin/env python3
import json
import os
import ssl
2026-03-18 14:40:48 +08:00
import time
from datetime import datetime
from pathlib import Path
2026-03-26 11:09:37 +08:00
from typing import Callable, Dict, Iterable, List, Optional, Tuple
2026-03-18 14:40:48 +08:00
from urllib import error, parse, request
2026-03-25 18:01:45 +08:00
try:
import psycopg # type: ignore
except ImportError:
psycopg = None
try:
import psycopg2 # type: ignore
from psycopg2.extras import RealDictCursor # type: ignore
except ImportError:
psycopg2 = None
RealDictCursor = None
2026-03-18 14:40:48 +08:00
ENV_PATH = Path(".env")
DEFAULT_BATCH_SIZE = 500
2026-03-26 10:52:35 +08:00
DEFAULT_HTTP_RETRIES = 3
DEFAULT_HTTP_RETRY_DELAY = 2.0
2026-03-25 18:01:45 +08:00
def create_feishu_tls12_context() -> ssl.SSLContext:
"""Create a verified TLS 1.2 context for Feishu API requests."""
context = ssl.create_default_context()
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.maximum_version = ssl.TLSVersion.TLSv1_2
return context
FEISHU_HTTP_OPENER = request.build_opener(request.HTTPSHandler(context=create_feishu_tls12_context()))
2026-03-25 18:01:45 +08:00
DB_QUERY = """
SELECT
fs.支付时间 AS 支付时间,
fs.平台订单号 AS 订单号,
fs.有赞分销单号 AS 分销单号,
dw.仓库名称 AS "仓库名称/供应商",
ds.店铺名称 AS 店铺名称,
dp.商品名称 AS 商品名称,
fs.销售数量 AS 商品数量,
fs.客服备注 AS 客服备注,
fs.买家留言 AS 买家留言,
dc.手机号_脱敏 AS 客户手机号,
CONCAT(fs.收货省份, fs.收货城市, fs.收货区域) AS 地址
FROM analytics_layer.fact_sales fs
-- 商品
LEFT JOIN analytics_layer.dim_product dp
ON fs.商品主键 = dp.商品主键
-- 仓库
LEFT JOIN analytics_layer.dim_warehouse dw
ON fs.仓库主键 = dw.仓库主键
-- 客户
LEFT JOIN analytics_layer.dim_customer dc
ON fs.客户主键 = dc.客户主键
-- 店铺
LEFT JOIN analytics_layer.dim_shop ds
ON fs.店铺主键 = ds.店铺主键
WHERE
-- 48小时未发货
fs.发货时间 IS NULL
AND fs.支付时间 <= CURRENT_TIMESTAMP - INTERVAL '48' HOUR
-- 排除平台已发货
AND COALESCE(fs.平台发货状态, 0) != 1
-- 排除关闭类订单
AND COALESCE(fs.订单状态, '') NOT IN ('已关闭', '交易关闭', '已取消')
-- 排除已发货/完成
AND COALESCE(fs.订单状态, '') NOT IN ('已发货', '已完成')
-- ❗新增:排除存在退款记录的订单
AND NOT EXISTS (
SELECT 1
FROM analytics_layer.fact_returns fr
WHERE fr.平台订单号 = fs.平台订单号
AND fr.子单号 = fs.子单号
)
ORDER BY fs.支付时间 DESC;
"""
2026-03-18 14:40:48 +08:00
# Feishu Bitable field type IDs.
FIELD_TYPE_TEXT = 1
FIELD_TYPE_NUMBER = 2
FIELD_TYPE_SINGLE_SELECT = 3
FIELD_TYPE_MULTI_SELECT = 4
FIELD_TYPE_DATETIME = 5
FIELD_TYPE_CHECKBOX = 7
FIELD_TYPE_PHONE = 13
FIELD_TYPE_URL = 15
2026-03-25 18:01:45 +08:00
FIELD_TYPE_HINTS = {
"支付时间": FIELD_TYPE_DATETIME,
"销售数量": FIELD_TYPE_NUMBER,
"商品数量": FIELD_TYPE_NUMBER,
}
2026-03-26 11:09:37 +08:00
LOGGER_WRITER: Optional[Callable[[str, str], None]] = None
2026-03-18 14:40:48 +08:00
2026-03-26 10:52:35 +08:00
def log(message: str, *, level: str = "INFO") -> None:
2026-03-26 11:09:37 +08:00
if LOGGER_WRITER is not None:
LOGGER_WRITER(level, message)
return
2026-03-26 10:52:35 +08:00
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] [{level}] {message}")
2026-03-26 11:09:37 +08:00
def set_logger_writer(writer: Optional[Callable[[str, str], None]]) -> None:
global LOGGER_WRITER
LOGGER_WRITER = writer
2026-03-18 14:40:48 +08:00
def load_env(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
2026-03-25 18:01:45 +08:00
def get_db_config() -> Dict[str, str]:
mapping = {
"host": "ANALYTICS_DB_HOST",
"port": "ANALYTICS_DB_PORT",
"user": "ANALYTICS_DB_USER",
"password": "ANALYTICS_DB_PASSWORD",
"dbname": "ANALYTICS_DB_NAME",
}
config: Dict[str, str] = {}
missing: List[str] = []
for key, env_name in mapping.items():
value = os.getenv(env_name)
if not value:
missing.append(env_name)
else:
config[key] = value
if missing:
raise RuntimeError(f"缺少数据库配置: {', '.join(missing)}")
return config
2026-03-18 14:40:48 +08:00
def parse_base_url(base_url: str) -> Tuple[str, str, Optional[str]]:
parsed = parse.urlparse(base_url)
path_parts = [part for part in parsed.path.split("/") if part]
if len(path_parts) < 2 or path_parts[0] != "base":
raise ValueError(f"无法从 FEISHU_BASE_URL 解析 app_token: {base_url}")
query = parse.parse_qs(parsed.query)
app_token = path_parts[1]
table_id = query.get("table", [None])[0]
view_id = query.get("view", [None])[0]
if not table_id:
raise ValueError(f"无法从 FEISHU_BASE_URL 解析 table_id: {base_url}")
return app_token, table_id, view_id
def feishu_request(
method: str,
url: str,
*,
token: Optional[str] = None,
payload: Optional[dict] = None,
2026-03-26 10:52:35 +08:00
retries: int = DEFAULT_HTTP_RETRIES,
retry_delay: float = DEFAULT_HTTP_RETRY_DELAY,
2026-03-18 14:40:48 +08:00
) -> dict:
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = None
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
2026-03-26 10:52:35 +08:00
for attempt in range(1, retries + 1):
req = request.Request(url=url, method=method, headers=headers, data=data)
try:
with FEISHU_HTTP_OPENER.open(req, timeout=60) as resp:
2026-03-26 10:52:35 +08:00
body = resp.read().decode("utf-8")
break
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
should_retry = exc.code in {429, 500, 502, 503, 504}
log(
f"Feishu API HTTP 异常: method={method} status={exc.code} "
f"url={url} attempt={attempt}/{retries} detail={detail[:500]}",
level="ERROR",
)
if should_retry and attempt < retries:
sleep_seconds = retry_delay * attempt
log(
f"Feishu API 将重试: method={method} status={exc.code} "
f"next_retry_in={sleep_seconds:.1f}s",
level="WARN",
)
time.sleep(sleep_seconds)
continue
raise RuntimeError(f"Feishu API 请求失败: {exc.code} {detail}") from exc
except (error.URLError, ssl.SSLError) as exc:
2026-03-26 10:52:35 +08:00
log(
f"Feishu API 网络异常: method={method} url={url} "
f"attempt={attempt}/{retries} error={exc}",
level="ERROR",
)
if attempt < retries:
sleep_seconds = retry_delay * attempt
log(
f"Feishu API 网络异常后重试: method={method} next_retry_in={sleep_seconds:.1f}s",
level="WARN",
)
time.sleep(sleep_seconds)
continue
raise RuntimeError(f"无法连接飞书 API: {exc}") from exc
2026-03-18 14:40:48 +08:00
result = json.loads(body)
if result.get("code") != 0:
2026-03-26 10:52:35 +08:00
log(
f"Feishu API 业务异常: method={method} url={url} "
f"code={result.get('code')} msg={result.get('msg')}",
level="ERROR",
)
2026-03-18 14:40:48 +08:00
raise RuntimeError(
f"Feishu API 返回错误: code={result.get('code')} msg={result.get('msg')} body={body}"
)
return result
2026-03-25 18:01:45 +08:00
def get_response_data(result: dict) -> dict:
data = result.get("data")
return data if isinstance(data, dict) else {}
2026-03-18 14:40:48 +08:00
def get_tenant_access_token(app_id: str, app_secret: str) -> str:
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
result = feishu_request(
"POST",
url,
payload={"app_id": app_id, "app_secret": app_secret},
)
return result["tenant_access_token"]
2026-03-26 10:52:35 +08:00
def send_feishu_group_message(text: str) -> None:
app_id = os.getenv("FEISHU_APP_ID")
app_secret = os.getenv("FEISHU_APP_SECRET")
chat_id = os.getenv("FEISHU_CHAT_ID")
if not app_id or not app_secret or not chat_id:
raise RuntimeError("缺少 FEISHU_APP_ID / FEISHU_APP_SECRET / FEISHU_CHAT_ID")
token = get_tenant_access_token(app_id, app_secret)
url = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id"
payload = {
"receive_id": chat_id,
"msg_type": "text",
"content": json.dumps({"text": text}, ensure_ascii=False),
}
feishu_request("POST", url, token=token, payload=payload)
def build_success_message(summary: Dict[str, int]) -> str:
base_url = os.getenv("FEISHU_BASE_URL", "")
started_at = summary.get("started_at", "")
finished_at = summary.get("finished_at", "")
message = (
"截止到中午12点,超48小时未发货的订单数据同步完成\n"
f"新增记录数: {summary['created']}\n"
)
if base_url:
message += f"\n多维表链接: {base_url}"
return message
def build_failure_message(error_text: str, finished_at: str) -> str:
return (
"物流多维表同步失败\n"
f"执行时间: {finished_at}\n"
f"错误信息: {error_text}"
)
2026-03-18 14:40:48 +08:00
def paginate_fields(app_token: str, table_id: str, token: str) -> List[dict]:
fields: List[dict] = []
page_token: Optional[str] = None
while True:
query = {"page_size": 100}
if page_token:
query["page_token"] = page_token
url = (
f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}"
f"/tables/{table_id}/fields?{parse.urlencode(query)}"
)
result = feishu_request("GET", url, token=token)
2026-03-25 18:01:45 +08:00
data = get_response_data(result)
2026-03-26 10:52:35 +08:00
items = data.get("items", [])
fields.extend(items)
2026-03-18 14:40:48 +08:00
if not data.get("has_more"):
break
page_token = data.get("page_token")
2026-03-26 10:52:35 +08:00
log(f"字段读取完成: total={len(fields)}")
2026-03-18 14:40:48 +08:00
return fields
2026-03-26 10:52:35 +08:00
def list_all_record_ids(app_token: str, table_id: str, token: str) -> List[str]:
record_ids: List[str] = []
page_token: Optional[str] = None
while True:
query = {"page_size": 100}
if page_token:
query["page_token"] = page_token
url = (
f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}"
f"/tables/{table_id}/records?{parse.urlencode(query)}"
)
result = feishu_request("GET", url, token=token)
data = get_response_data(result)
items = data.get("items", [])
for item in items:
record_id = item.get("record_id") or item.get("id")
if record_id:
record_ids.append(record_id)
if not data.get("has_more"):
break
page_token = data.get("page_token")
if not page_token:
break
log(f"目标表现有记录统计: total={len(record_ids)}")
return record_ids
2026-03-25 18:01:45 +08:00
def create_field(app_token: str, table_id: str, token: str, field_name: str, field_type: int) -> dict:
2026-03-18 14:40:48 +08:00
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
payload = {
"field_name": field_name,
2026-03-25 18:01:45 +08:00
"type": field_type,
2026-03-18 14:40:48 +08:00
}
result = feishu_request("POST", url, token=token, payload=payload)
2026-03-25 18:01:45 +08:00
data = get_response_data(result)
field = data.get("field")
if not isinstance(field, dict):
raise RuntimeError(f"创建字段返回异常,缺少 field: {json.dumps(result, ensure_ascii=False)}")
return field
2026-03-18 14:40:48 +08:00
def convert_for_field(value: Optional[str], field_type: int):
if value is None:
return None
if field_type in {FIELD_TYPE_TEXT, FIELD_TYPE_PHONE, FIELD_TYPE_URL}:
return value
if field_type == FIELD_TYPE_NUMBER:
try:
return float(value) if "." in value else int(value)
except ValueError:
return value
if field_type == FIELD_TYPE_SINGLE_SELECT:
return value
if field_type == FIELD_TYPE_MULTI_SELECT:
return [part.strip() for part in value.split(",") if part.strip()]
if field_type == FIELD_TYPE_CHECKBOX:
return value.lower() in {"1", "true", "yes", "y", "是"}
if field_type == FIELD_TYPE_DATETIME:
2026-03-26 10:52:35 +08:00
for pattern in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S+08", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(value, pattern)
if pattern == "%Y-%m-%d":
dt = datetime(dt.year, dt.month, dt.day)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return value
2026-03-18 14:40:48 +08:00
return value
2026-03-25 18:01:45 +08:00
def normalize_db_value(value) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
2026-03-26 10:52:35 +08:00
return value.strftime("%Y-%m-%d")
2026-03-25 18:01:45 +08:00
return str(value).strip() or None
def query_db_rows(sql: str) -> Tuple[List[str], List[Dict[str, Optional[str]]]]:
config = get_db_config()
if psycopg is not None:
with psycopg.connect(**config) as conn:
with conn.cursor() as cursor:
cursor.execute("SET TIMEZONE = 'Asia/Shanghai'")
cursor.execute(sql)
headers = [desc.name for desc in cursor.description or []]
result_rows = cursor.fetchall()
rows = []
for item in result_rows:
rows.append({header: normalize_db_value(value) for header, value in zip(headers, item)})
return headers, rows
if psycopg2 is not None and RealDictCursor is not None:
with psycopg2.connect(**config) as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SET TIMEZONE = 'Asia/Shanghai'")
cursor.execute(sql)
raw_rows = cursor.fetchall()
headers = [desc.name for desc in cursor.description or []]
rows = []
for item in raw_rows:
rows.append({header: normalize_db_value(item.get(header)) for header in headers})
return headers, rows
raise RuntimeError("缺少 PostgreSQL 驱动,请安装 psycopg 或 psycopg2")
2026-03-18 14:40:48 +08:00
def ensure_fields(
app_token: str,
table_id: str,
token: str,
csv_headers: Iterable[str],
) -> Tuple[Dict[str, dict], List[str]]:
fields = paginate_fields(app_token, table_id, token)
field_map = {field["field_name"]: field for field in fields}
missing_headers: List[str] = []
for header in csv_headers:
if header not in field_map:
try:
2026-03-25 18:01:45 +08:00
created = create_field(
app_token,
table_id,
token,
header,
FIELD_TYPE_HINTS.get(header, FIELD_TYPE_TEXT),
)
2026-03-18 14:40:48 +08:00
field_map[header] = created
2026-03-26 10:52:35 +08:00
log(f"字段已创建: field_name={header}")
2026-03-18 14:40:48 +08:00
except RuntimeError as exc:
missing_headers.append(header)
2026-03-26 10:52:35 +08:00
log(f"字段创建失败,已跳过: field_name={header} error={exc}", level="WARN")
2026-03-18 14:40:48 +08:00
return field_map, missing_headers
def chunked(items: List[dict], size: int) -> Iterable[List[dict]]:
for index in range(0, len(items), size):
yield items[index : index + size]
def build_records(
rows: List[Dict[str, Optional[str]]],
field_map: Dict[str, dict],
skipped_headers: Iterable[str],
) -> List[dict]:
skipped = set(skipped_headers)
records: List[dict] = []
for row in rows:
fields_payload = {}
for key, raw_value in row.items():
if key in skipped or key not in field_map:
continue
if raw_value is None:
continue
field_type = field_map[key]["type"]
value = convert_for_field(raw_value, field_type)
if value is not None:
fields_payload[key] = value
if fields_payload:
records.append({"fields": fields_payload})
return records
def batch_create_records(
app_token: str,
table_id: str,
token: str,
records: List[dict],
batch_size: int,
) -> int:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
created = 0
total_batches = (len(records) + batch_size - 1) // batch_size
for batch_index, batch in enumerate(chunked(records, batch_size), start=1):
2026-03-26 10:52:35 +08:00
log(f"开始写入批次: batch={batch_index}/{total_batches} size={len(batch)}")
2026-03-18 14:40:48 +08:00
feishu_request("POST", url, token=token, payload={"records": batch})
created += len(batch)
2026-03-26 10:52:35 +08:00
log(f"写入批次完成: batch={batch_index}/{total_batches} created_total={created}")
2026-03-18 14:40:48 +08:00
time.sleep(0.15)
return created
2026-03-26 10:52:35 +08:00
def batch_delete_records(
app_token: str,
table_id: str,
token: str,
record_ids: List[str],
batch_size: int,
) -> int:
if not record_ids:
log("目标表当前没有历史记录,无需删除")
return 0
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete"
deleted = 0
total_batches = (len(record_ids) + batch_size - 1) // batch_size
for batch_index, batch in enumerate(chunked(record_ids, batch_size), start=1):
log(f"开始删除批次: batch={batch_index}/{total_batches} size={len(batch)}")
result = feishu_request("POST", url, token=token, payload={"records": batch})
data = get_response_data(result)
response_records = data.get("records", [])
if response_records:
failed = [item.get("record_id") for item in response_records if not item.get("deleted", False)]
if failed:
log(f"删除批次存在失败记录: failed_count={len(failed)}", level="ERROR")
raise RuntimeError(f"删除记录失败,record_ids={failed}")
deleted += len(batch)
log(f"删除批次完成: batch={batch_index}/{total_batches} deleted_total={deleted}")
time.sleep(0.15)
return deleted
2026-03-18 14:40:48 +08:00
def print_permission_help(message: str) -> None:
2026-03-26 10:52:35 +08:00
log("飞书返回权限不足,当前应用还不能向这个多维表新增字段或记录。", level="ERROR")
log(f"原始错误: {message}", level="ERROR")
log(
2026-03-18 14:40:48 +08:00
"请在飞书开放平台或多维表权限设置中,确认这个应用已被添加到该 Base,且具备多维表读取/写入权限。",
2026-03-26 10:52:35 +08:00
level="ERROR",
2026-03-18 14:40:48 +08:00
)
2026-03-26 10:52:35 +08:00
log(
2026-03-18 14:40:48 +08:00
"如果你手动补齐缺失字段并授权写入,重新执行 `python3 import_logistics_to_feishu_bitable.py` 即可继续导入。",
2026-03-26 10:52:35 +08:00
level="ERROR",
2026-03-18 14:40:48 +08:00
)
2026-03-26 10:52:35 +08:00
def execute_sync() -> Dict[str, int]:
2026-03-18 14:40:48 +08:00
load_env(ENV_PATH)
app_id = os.getenv("FEISHU_APP_ID")
app_secret = os.getenv("FEISHU_APP_SECRET")
base_url = os.getenv("FEISHU_BASE_URL")
batch_size = int(os.getenv("BATCH_SIZE", str(DEFAULT_BATCH_SIZE)))
if not app_id or not app_secret or not base_url:
2026-03-26 10:52:35 +08:00
raise RuntimeError("缺少 FEISHU_APP_ID / FEISHU_APP_SECRET / FEISHU_BASE_URL")
2026-03-18 14:40:48 +08:00
2026-03-26 10:52:35 +08:00
started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
2026-03-18 14:40:48 +08:00
app_token, table_id, _view_id = parse_base_url(base_url)
2026-03-26 10:52:35 +08:00
log(f"导入任务开始: source=db table_id={table_id}")
headers, rows = query_db_rows(DB_QUERY)
log(f"读取数据库完成: rows={len(rows)}")
2026-03-18 14:40:48 +08:00
try:
token = get_tenant_access_token(app_id, app_secret)
2026-03-26 10:52:35 +08:00
log("已获取 tenant_access_token")
2026-03-18 14:40:48 +08:00
field_map, missing_headers = ensure_fields(app_token, table_id, token, headers)
2026-03-26 10:52:35 +08:00
log(f"字段检查完成: field_count={len(field_map)} missing_count={len(missing_headers)}")
existing_record_ids = list_all_record_ids(app_token, table_id, token)
deleted = batch_delete_records(app_token, table_id, token, existing_record_ids, batch_size)
2026-03-18 14:40:48 +08:00
records = build_records(rows, field_map, missing_headers)
2026-03-26 10:52:35 +08:00
log(f"记录构建完成: source_rows={len(rows)} upload_records={len(records)}")
2026-03-18 14:40:48 +08:00
created = batch_create_records(app_token, table_id, token, records, batch_size)
except RuntimeError as exc:
if "91403" in str(exc) or "Forbidden" in str(exc):
print_permission_help(str(exc))
2026-03-26 10:52:35 +08:00
raise RuntimeError("飞书多维表权限不足") from exc
log(f"导入任务失败: error={exc}", level="ERROR")
2026-03-18 14:40:48 +08:00
raise
if missing_headers:
2026-03-26 10:52:35 +08:00
log(f"存在未写入字段: missing_count={len(missing_headers)}", level="WARN")
log(f"导入任务完成: deleted={deleted} created={created} source_rows={len(rows)}")
return {
"started_at": started_at,
"finished_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"deleted": deleted,
"created": created,
"source_rows": len(rows),
"missing_count": len(missing_headers),
}
def main() -> int:
try:
summary = execute_sync()
try:
send_feishu_group_message(build_success_message(summary))
log("飞书群通知发送成功")
except Exception as notify_exc:
log(f"飞书群通知发送失败: error={notify_exc}", level="ERROR")
return 0
except RuntimeError as exc:
try:
send_feishu_group_message(
build_failure_message(str(exc), datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
)
log("失败通知已发送到飞书群", level="WARN")
except Exception as notify_exc:
log(f"失败通知发送失败: error={notify_exc}", level="ERROR")
if str(exc) == "飞书多维表权限不足":
return 2
raise
2026-03-18 14:40:48 +08:00
if __name__ == "__main__":
raise SystemExit(main())