463 lines
13 KiB
Python
463 lines
13 KiB
Python
"""银行交易流水同步服务。
|
|
|
|
提供内部方法供定时任务调用,从外部银行接口拉取交易流水并存储到数据库。
|
|
|
|
定时任务配置 JSON 格式:
|
|
{
|
|
"url": "http://ceshi.web.minzhong.cn/api/api/v1/internal/get-blank-transfer-acct-time",
|
|
"api_key": "jixekCxm8piLFi0AlfA24bDtCKJ82bfu",
|
|
"acct_no": "110972289710001",
|
|
"start_date": "2026-08-01",
|
|
"end_date": "2026-08-14",
|
|
"dc_flag": null
|
|
}
|
|
|
|
服务会自动翻页查询所有数据,直到拉完全部流水。
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
|
|
from app.models.base import async_session
|
|
from app.models.bank_account import BankAccount
|
|
from app.models.bank_transaction import BankTransaction
|
|
from app.services.bank.file_logger import log_bank_api_request
|
|
from app.utils.id_gen import generate_id
|
|
|
|
logger = logging.getLogger("video_gen")
|
|
|
|
# 每页条数(固定)
|
|
_PAGE_SIZE = 100
|
|
|
|
|
|
async def sync_bank_transactions(
|
|
url: str = "",
|
|
api_key: str = "",
|
|
acct_no: str = "",
|
|
start_date: str = "",
|
|
end_date: str = "",
|
|
dc_flag: int | None = None,
|
|
) -> dict:
|
|
"""同步银行交易流水到数据库(内部方法,供定时任务调用)。
|
|
|
|
从配置中读取请求参数,自动翻页调用外部银行接口拉取全部交易流水并存储。
|
|
|
|
:param url: 接口地址
|
|
:param api_key: 接口密钥
|
|
:param acct_no: 银行账号
|
|
:param start_date: 开始日期 (YYYY-MM-DD)
|
|
:param end_date: 结束日期 (YYYY-MM-DD)
|
|
:param dc_flag: 借贷方向 (0=出金, 1=入金, None=全部)
|
|
:return: 同步结果统计
|
|
"""
|
|
url = url.strip()
|
|
api_key = api_key.strip()
|
|
acct_no = acct_no.strip()
|
|
start_date = start_date.strip()
|
|
end_date = end_date.strip()
|
|
|
|
if not url:
|
|
raise ValueError("配置中缺少 url(接口地址)")
|
|
if not acct_no:
|
|
raise ValueError("配置中缺少 acct_no(银行账号)")
|
|
|
|
# 默认日期范围
|
|
today = datetime.now(timezone.utc).date()
|
|
if not end_date:
|
|
end_date = today.isoformat()
|
|
if not start_date:
|
|
start_date = today.isoformat()
|
|
|
|
sync_batch = generate_id()
|
|
stats = {
|
|
"sync_batch": sync_batch,
|
|
"acct_no": acct_no,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"pages_fetched": 0,
|
|
"transactions_fetched": 0,
|
|
"transactions_new": 0,
|
|
"transactions_dup": 0,
|
|
"errors": [],
|
|
}
|
|
|
|
await _do_sync(
|
|
url=url,
|
|
api_key=api_key,
|
|
acct_no=acct_no,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
dc_flag=dc_flag,
|
|
sync_batch=sync_batch,
|
|
stats=stats,
|
|
)
|
|
|
|
logger.info(
|
|
"银行流水同步完成: batch=%s, acct=%s, 翻页=%d, 获取=%d, 新增=%d, 重复=%d",
|
|
sync_batch,
|
|
acct_no,
|
|
stats["pages_fetched"],
|
|
stats["transactions_fetched"],
|
|
stats["transactions_new"],
|
|
stats["transactions_dup"],
|
|
)
|
|
return stats
|
|
|
|
|
|
async def _do_sync(
|
|
url: str,
|
|
api_key: str,
|
|
acct_no: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
dc_flag: int | None,
|
|
sync_batch: str,
|
|
stats: dict,
|
|
) -> None:
|
|
"""执行同步逻辑(自动翻页)。"""
|
|
async with async_session() as db:
|
|
# 查找对应的银行账户
|
|
result = await db.execute(
|
|
select(BankAccount).where(BankAccount.account_no == acct_no)
|
|
)
|
|
account = result.scalar_one_or_none()
|
|
account_id = account.id if account else ""
|
|
|
|
# 自动翻页拉取全部数据
|
|
all_transactions: list[dict] = []
|
|
page = 1
|
|
total_count = None
|
|
|
|
while True:
|
|
data = await _fetch_transactions(
|
|
url=url,
|
|
api_key=api_key,
|
|
acct_no=acct_no,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
dc_flag=dc_flag,
|
|
page=page,
|
|
)
|
|
stats["pages_fetched"] += 1
|
|
|
|
# 解析交易记录
|
|
transactions = _parse_transactions(data)
|
|
all_transactions.extend(transactions)
|
|
|
|
# 获取总数(仅第一页)
|
|
if total_count is None:
|
|
total_count = _extract_total(data)
|
|
|
|
logger.info(
|
|
"银行流水同步翻页: page=%d, 本页=%d, 累计=%d, 总数=%s",
|
|
page, len(transactions), len(all_transactions), total_count,
|
|
)
|
|
|
|
# 判断是否还有下一页
|
|
if len(transactions) < _PAGE_SIZE:
|
|
break
|
|
if total_count is not None and len(all_transactions) >= total_count:
|
|
break
|
|
|
|
page += 1
|
|
|
|
# 安全限制:最多翻 100 页
|
|
if page > 100:
|
|
logger.warning("银行流水同步达到翻页上限: page=100")
|
|
break
|
|
|
|
stats["transactions_fetched"] = len(all_transactions)
|
|
|
|
# 逐条写入数据库(按 transaction_no 去重)
|
|
for tx in all_transactions:
|
|
tx["account_id"] = account_id
|
|
tx["account_no"] = acct_no
|
|
tx["sync_batch"] = sync_batch
|
|
|
|
tx_no = tx.get("transaction_no")
|
|
if tx_no:
|
|
existing = await db.execute(
|
|
select(BankTransaction).where(
|
|
BankTransaction.transaction_no == tx_no
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
stats["transactions_dup"] += 1
|
|
continue
|
|
|
|
transaction = BankTransaction(**tx)
|
|
db.add(transaction)
|
|
stats["transactions_new"] += 1
|
|
|
|
await db.commit()
|
|
|
|
|
|
def _extract_total(data: dict | None) -> int | None:
|
|
"""从接口返回中提取总条数。"""
|
|
if not data or not isinstance(data, dict):
|
|
return None
|
|
inner = data.get("data") or {}
|
|
if isinstance(inner, dict):
|
|
return inner.get("total")
|
|
return None
|
|
|
|
|
|
async def _fetch_transactions(
|
|
url: str,
|
|
api_key: str,
|
|
acct_no: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
dc_flag: int | None,
|
|
page: int,
|
|
) -> dict:
|
|
"""调用外部银行接口获取交易流水(单页)。"""
|
|
import time
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
if api_key:
|
|
headers["api-key"] = api_key
|
|
|
|
payload: dict = {
|
|
"acct_no": acct_no,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"page": page,
|
|
"page_size": _PAGE_SIZE,
|
|
}
|
|
if dc_flag is not None:
|
|
payload["dc_flag"] = dc_flag
|
|
|
|
request_params = {k: v for k, v in payload.items()}
|
|
start_time = time.monotonic()
|
|
timeout = httpx.Timeout(30.0)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
response = await client.post(url, headers=headers, json=payload)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
log_bank_api_request(
|
|
acct_no=acct_no,
|
|
request_params=request_params,
|
|
response_data=data,
|
|
status_code=response.status_code,
|
|
is_success=True,
|
|
admin_id="scheduled_task",
|
|
duration_ms=duration_ms,
|
|
)
|
|
return data
|
|
except httpx.HTTPStatusError as e:
|
|
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
log_bank_api_request(
|
|
acct_no=acct_no,
|
|
request_params=request_params,
|
|
response_data=None,
|
|
status_code=e.response.status_code,
|
|
is_success=False,
|
|
error_msg=f"HTTP {e.response.status_code}",
|
|
admin_id="scheduled_task",
|
|
duration_ms=duration_ms,
|
|
)
|
|
raise ValueError(f"银行接口返回错误: HTTP {e.response.status_code}") from e
|
|
except httpx.TimeoutException as e:
|
|
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
log_bank_api_request(
|
|
acct_no=acct_no,
|
|
request_params=request_params,
|
|
response_data=None,
|
|
status_code=None,
|
|
is_success=False,
|
|
error_msg="请求超时",
|
|
admin_id="scheduled_task",
|
|
duration_ms=duration_ms,
|
|
)
|
|
raise ValueError("银行接口请求超时") from e
|
|
except httpx.RequestError as e:
|
|
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
log_bank_api_request(
|
|
acct_no=acct_no,
|
|
request_params=request_params,
|
|
response_data=None,
|
|
status_code=None,
|
|
is_success=False,
|
|
error_msg=str(e),
|
|
admin_id="scheduled_task",
|
|
duration_ms=duration_ms,
|
|
)
|
|
raise ValueError(f"银行接口请求失败: {e}") from e
|
|
|
|
|
|
def _parse_transactions(data: dict | None) -> list[dict]:
|
|
"""解析接口返回的数据为交易记录列表。
|
|
|
|
接口返回格式:
|
|
{
|
|
"code": 200,
|
|
"message": "成功",
|
|
"data": {
|
|
"list": [...],
|
|
"total": 1,
|
|
"page": 1,
|
|
"page_size": 20
|
|
}
|
|
}
|
|
"""
|
|
if not data:
|
|
return []
|
|
|
|
# 提取列表
|
|
records = []
|
|
if isinstance(data, dict):
|
|
inner = data.get("data") or {}
|
|
if isinstance(inner, dict):
|
|
records = inner.get("list") or []
|
|
elif isinstance(inner, list):
|
|
records = inner
|
|
elif isinstance(data, list):
|
|
records = data
|
|
|
|
transactions = []
|
|
for item in records:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
tx = _map_record(item)
|
|
transactions.append(tx)
|
|
return transactions
|
|
|
|
|
|
def _map_record(item: dict) -> dict:
|
|
"""将接口返回的单条记录映射为 BankTransaction 字段。"""
|
|
# 流水号:优先 tellerSeqno,其次 transferId
|
|
tx_no = (
|
|
item.get("tellerSeqno")
|
|
or item.get("transferId")
|
|
or item.get("transactionNo")
|
|
or item.get("transaction_no")
|
|
or ""
|
|
)
|
|
|
|
# 交易时间
|
|
tx_time_raw = (
|
|
item.get("transTimeStr")
|
|
or item.get("trans_time")
|
|
or item.get("transactionTime")
|
|
or item.get("transaction_time")
|
|
or ""
|
|
)
|
|
tx_time = _parse_datetime(tx_time_raw)
|
|
|
|
# 交易金额
|
|
amount = (
|
|
item.get("transAmt")
|
|
or item.get("amount")
|
|
or item.get("transactionAmount")
|
|
or item.get("transaction_amount")
|
|
or "0"
|
|
)
|
|
|
|
# 借贷方向
|
|
direction = _resolve_direction(item.get("dcFlag"), item.get("dcFlagLabel"))
|
|
|
|
# 余额
|
|
balance = (
|
|
item.get("balance")
|
|
or item.get("balanceAfter")
|
|
or item.get("balance_after")
|
|
)
|
|
|
|
# 对方信息
|
|
counterparty_name = (
|
|
item.get("cnterName")
|
|
or item.get("counterpartyName")
|
|
or item.get("counterparty_name")
|
|
)
|
|
|
|
counterparty_account = (
|
|
item.get("counterAcctNo")
|
|
or item.get("counterpartyAccount")
|
|
or item.get("counterparty_account")
|
|
)
|
|
|
|
counterparty_bank = (
|
|
item.get("cnterBankName")
|
|
or item.get("counterpartyBank")
|
|
or item.get("counterparty_bank")
|
|
)
|
|
|
|
# 摘要/备注
|
|
remark = (
|
|
item.get("remark")
|
|
or item.get("memo")
|
|
)
|
|
|
|
digest = item.get("digestCode") or item.get("digest_code")
|
|
|
|
# 我方账号
|
|
acct_no = (
|
|
item.get("acctNo")
|
|
or item.get("acct_no")
|
|
or ""
|
|
)
|
|
|
|
return {
|
|
"id": generate_id(),
|
|
"transaction_no": tx_no or None,
|
|
"transaction_time": tx_time,
|
|
"transaction_amount": str(amount) if amount else "0",
|
|
"balance_direction": direction,
|
|
"balance_after": str(balance) if balance else None,
|
|
"counterparty_name": counterparty_name,
|
|
"counterparty_account": counterparty_account,
|
|
"counterparty_bank": counterparty_bank,
|
|
"remark": remark,
|
|
"digest_code": digest,
|
|
"raw_data": json.dumps(item, ensure_ascii=False, default=str),
|
|
"is_synced": True,
|
|
"account_no": acct_no,
|
|
}
|
|
|
|
|
|
def _resolve_direction(dc_flag, dc_flag_label: str | None = None) -> str | None:
|
|
"""解析借贷方向。"""
|
|
if dc_flag_label:
|
|
label = str(dc_flag_label)
|
|
if label.startswith("借") or "出金" in label:
|
|
return "DR"
|
|
if label.startswith("贷") or "入金" in label:
|
|
return "CR"
|
|
|
|
if dc_flag is not None:
|
|
if dc_flag in (0, "0"):
|
|
return "DR"
|
|
if dc_flag in (1, "1"):
|
|
return "CR"
|
|
|
|
return None
|
|
|
|
|
|
def _parse_datetime(value) -> datetime | None:
|
|
"""解析日期时间字符串为 datetime 对象。"""
|
|
if not value or not isinstance(value, str):
|
|
return None
|
|
formats = [
|
|
"%Y-%m-%d %H:%M:%S",
|
|
"%Y-%m-%dT%H:%M:%S",
|
|
"%Y-%m-%dT%H:%M:%S%z",
|
|
"%Y-%m-%dT%H:%M:%S.%f",
|
|
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
"%Y-%m-%d %H:%M",
|
|
"%Y-%m-%d",
|
|
]
|
|
for fmt in formats:
|
|
try:
|
|
return datetime.strptime(value, fmt)
|
|
except ValueError:
|
|
continue
|
|
return None
|