"""上市公司基本信息(tushare stock_company,详情页按需单查懒加载)。 不做批量同步:详情页打开才触发,ts_code= 单查一次一调用; 行即缓存 —— stock_company 表 30 天新鲜度门控(数据月更),tushare 查无此股 写墓碑行(业务字段全 NULL)做负缓存,避免无数据代码每次都穿透(0.35s 控频 + 可能 62s 限频重试)。墓碑同样按 updated_at 参与 30 天刷新,新股上市后能自动补上。 """ from __future__ import annotations import asyncio import time from datetime import datetime from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings from ..db import async_session from ..models import StockCompany from .sync_utils import call_retry, f_clean, fresh, get_pro_lazy, s_clean, utcnow _REFRESH_DAYS = 30 # 显式列出全部字段:introduction/office/main_business/business_scope 文档标注默认不显示, # 不传 fields 时 tushare 不返回这四列(实测 000001.SZ) _FIELDS = ( "ts_code,com_name,com_id,chairman,manager,secretary,reg_capital," "setup_date,province,city,introduction,website,email,office," "employees,main_business,business_scope" ) def _i(v) -> int | None: f = f_clean(v) return None if f is None else int(f) def fetch_company_sync(ts_code: str) -> dict | None: """同步拉单只公司简介(需在 to_thread 里跑);返回行 dict,无此股返回 None。""" time.sleep(settings.screener_sync_interval) df = call_retry(get_pro_lazy().stock_company, ts_code=ts_code, fields=_FIELDS) if df is None or df.empty: return None r = df.iloc[0] return { "ts_code": ts_code, "com_name": s_clean(r.get("com_name")), "com_id": s_clean(r.get("com_id")), "chairman": s_clean(r.get("chairman")), "manager": s_clean(r.get("manager")), "secretary": s_clean(r.get("secretary")), "reg_capital": f_clean(r.get("reg_capital")), "setup_date": s_clean(r.get("setup_date")), "province": s_clean(r.get("province")), "city": s_clean(r.get("city")), "introduction": s_clean(r.get("introduction")), "website": s_clean(r.get("website")), "email": s_clean(r.get("email")), "office": s_clean(r.get("office")), "employees": _i(r.get("employees")), "main_business": s_clean(r.get("main_business")), "business_scope": s_clean(r.get("business_scope")), "updated_at": utcnow(), } _COLS = ( "ts_code", "com_name", "com_id", "chairman", "manager", "secretary", "reg_capital", "setup_date", "province", "city", "introduction", "website", "email", "office", "employees", "main_business", "business_scope", ) def _row_dict(row: StockCompany) -> dict: return {c: getattr(row, c) for c in _COLS} async def _upsert(session: AsyncSession, row: dict) -> None: stmt = pg_insert(StockCompany).values(row) stmt = stmt.on_conflict_do_update( index_elements=["ts_code"], set_={c: stmt.excluded[c] for c in row if c != "ts_code"}, ) await session.execute(stmt) await session.commit() # per-code 锁:同 code 首次并发 N 个请求只有 1 个打 tushare,其余等锁后双检命中; # dict 不清理(对象数 = 全市场股票数,内存可忽略)。uvicorn 单进程场景够用, # 多 worker 最坏情况是重复拉一次 + ON CONFLICT 幂等,无害。 _code_locks: dict[str, asyncio.Lock] = {} _guard = asyncio.Lock() async def get_company(session: AsyncSession, ts_code: str) -> dict | None: """读穿透:新鲜行直返;否则加 per-code 锁 -> 新会话双检 -> to_thread 拉 -> upsert。 返回 None = 确认无数据(墓碑已落库);抛异常 = tushare 拉取失败且无旧行可降级。 """ row = (await session.execute( select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none() if row is not None and fresh(row.updated_at, _REFRESH_DAYS): return _row_dict(row) if row.com_name is not None else None # 墓碑 -> None # 释放请求会话持有的连接:后面可能隔着 1-2s 的 tushare 调用,别长占连接池。 # 用 close() 而非 rollback():rollback 会把会话身份映射里的实例全部 expire—— # 包括 require_user 刚塞进 auth._session_cache 的 User,下个请求命中鉴权缓存即 # DetachedInstanceError 500;close() 同样归还连接且已加载属性保持可访问。 await session.close() async with _guard: lock = _code_locks.setdefault(ts_code, asyncio.Lock()) async with lock: async with async_session() as s2: # 锁内重读 + 写入走新会话 row = (await s2.execute( select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none() if row is not None and fresh(row.updated_at, _REFRESH_DAYS): return _row_dict(row) if row.com_name is not None else None try: fetched = await asyncio.to_thread(fetch_company_sync, ts_code) except Exception: # 降级:库内有真实旧行(哪怕超 30 天)照常返回,不把「上游挂了」伪装成「无数据」 if row is not None and row.com_name is not None: return _row_dict(row) raise await _upsert(s2, fetched or {"ts_code": ts_code, "updated_at": utcnow()}) return fetched