From f6ddd0263a8b607b1e064c3b3b2c12c93a17af92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 25 Aug 2026 14:32:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(repos):=20int=20=E5=BC=BA=E8=BD=AC=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20String=20=E5=A3=B0=E6=98=8E=E5=88=97(=E5=88=97?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E4=BC=98=E5=85=88=E4=BA=8E=20=5FINT=5FFIELDS?= =?UTF-8?q?=20=E6=8C=89=E5=90=8D=E9=9B=86=E5=90=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 评审遗留:_INT_FIELDS 含 type_raw(为 Message int code 加),但 MomentsPost.type_raw 是 String(16)——按名强转会把非数字 code(如 'text')置 None,违背列声明。_build_column_values 现在检查目标列 声明类型:isinstance(col.type, sa.String)(含 Text)则跳过强转, Message.type_raw(Integer) 照旧。另修正 upsert_chat_group 文档串里 过时的 'PK = room_id'(复合键自 8a99940)。 Co-Authored-By: Claude --- yunke-server/app/storage/repos.py | 18 ++++++--- yunke-server/tests/test_repos.py | 61 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/yunke-server/app/storage/repos.py b/yunke-server/app/storage/repos.py index e3ddf28..35733d0 100644 --- a/yunke-server/app/storage/repos.py +++ b/yunke-server/app/storage/repos.py @@ -23,9 +23,11 @@ dict (see :func:`_coerce_bool`, :func:`_coerce_int`): ``is_upload``): ``1`` / ``"1"`` / ``True`` -> ``True``; ``0`` / ``"0"`` / ``False`` -> ``False``; ``None`` preserved. * ``gender``: ``"0"`` / ``"1"`` / ``"2"`` (str) -> ``int`` when not ``None``. -* ``type_raw`` (Message): the raw upstream type code, coerced to - ``int | None``. ``Message.type`` is a normalized label string and is - stored as-is (no int coercion). +* ``type_raw``: the raw upstream type code. Coerced to ``int | None`` **only + when the target column is an Integer/BigInteger column** (``Message.type_raw``) + — a same-named column declared as String (``MomentsPost.type_raw``) keeps the + value verbatim (column declaration wins over the global by-name set). + ``Message.type`` / ``MomentsPost`` label strings are stored as-is. * PhoneCall int fields (``call_duration`` / ``start_call_time`` / ``call_status`` / ``incoming_call`` / ``qc_status``): coerced to ``int | None``. The voice-call scraper already parses these to ints @@ -240,7 +242,12 @@ def _build_column_values( v = rec[col_name] if col_name in _BOOL_FIELDS: v = _coerce_bool(v) - elif col_name in _INT_FIELDS: + elif col_name in _INT_FIELDS and not isinstance(col.type, sa.String): + # 列声明类型优先于全局按名集合(2026-08-25 评审遗留修复): + # 同名列在不同表可声明不同类型 —— Message.type_raw 是 + # Integer(照旧强转),而 MomentsPost.type_raw 是 String(16), + # 强转会把非数字 code(如 "text")置 None,违背列声明。 + # sa.Text 继承 sa.String,同样跳过。 v = _coerce_int(v) # Some upstream fields are lists (e.g. Customer.deptRoute is a # list of dept ids) but map to scalar String columns — join so the @@ -512,7 +519,8 @@ async def upsert_whatsapp_account(session: AsyncSession, rec: dict[str, Any]) -> async def upsert_chat_group(session: AsyncSession, rec: dict[str, Any]) -> None: """Idempotently upsert one :class:`ChatGroup` AND its :class:`GroupMember` rows. - PK = ``room_id``. The group row is upserted first; then, for every id in + PK = composite ``(room_id, channel, owner_account_id)`` (同一群在每个 + 员工名下各一行). The group row is upserted first; then, for every id in the record's ``member_ids_list`` (the scraper-parsed, empties-dropped member wxid list), a :class:`GroupMember` row is upserted against the composite PK ``(channel, room_id, member_id)``. Re-scraping a group diff --git a/yunke-server/tests/test_repos.py b/yunke-server/tests/test_repos.py index 95c9f36..104c501 100644 --- a/yunke-server/tests/test_repos.py +++ b/yunke-server/tests/test_repos.py @@ -36,6 +36,7 @@ from app.db.models import ( WhatsappAccount, ) from app.storage.repos import ( + _build_column_values, _coerce_bool, _coerce_int, upsert_chat_group, @@ -1459,3 +1460,63 @@ async def test_upsert_moments_posts_idempotent_keeps_json_lists_and_coerces_crea assert rows[0].type_raw == "1" # MomentsPost type_raw is a string code finally: await engine.dispose() + + +# --------------------------------------------------------------------------- +# int 强转 vs String 列声明(2026-08-25 评审遗留修复) +# _INT_FIELDS 按列名全局命中,但列类型优先:目标列声明为 String(Text 继承) +# 则跳过强转 —— MomentsPost.type_raw 是 String(16),非数字 code(如 "text") +# 不能被 _coerce_int 置 None;Message.type_raw(Integer)照旧强转。 +# --------------------------------------------------------------------------- + + +async def test_int_coercion_skipped_for_string_columns(): + """String 声明的 type_raw 原样保留;Integer 声明的照旧强转。""" + # MomentsPost.type_raw = String(16) -> 不强转 + vals = _build_column_values( + MomentsPost, {"id": "x", "wechat_id": "w", "type_raw": "1"} + ) + assert vals["type_raw"] == "1" + vals = _build_column_values( + MomentsPost, {"id": "x", "wechat_id": "w", "type_raw": "text"} + ) + assert vals["type_raw"] == "text" + + # Message.type_raw = Integer -> "1" -> 1,非数字 -> None + vals = _build_column_values( + Message, {"msg_id": "m", "channel": "wechat", "type_raw": "1"} + ) + assert vals["type_raw"] == 1 + vals = _build_column_values( + Message, {"msg_id": "m", "channel": "wechat", "type_raw": "text"} + ) + assert vals["type_raw"] is None + + +async def test_moments_post_non_numeric_type_raw_round_trip(): + """非数字 type_raw code 入库读回仍是原字符串(不被 _coerce_int 置 None)。""" + engine = await _make_engine() + try: + SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + async with SessionLocal() as session: + await upsert_moments_posts( + session, + [ + { + "id": "p1", + "wechat_id": "wxid_x", + "type_raw": "video", # 假设未来出现的非数字 code + "create_time": 1776302051000, + } + ], + ) + await session.commit() + rows = ( + await session.execute( + sa.select(MomentsPost).execution_options(populate_existing=True) + ) + ).scalars().all() + assert len(rows) == 1 + assert rows[0].type_raw == "video" + finally: + await engine.dispose()