"""银行交易流水同步服务。 提供内部方法供定时任务调用,从外部银行接口拉取交易流水并存储到数据库。 定时任务配置 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