From 74a4819f8260603dbdb09d04772d105c6a62a01e Mon Sep 17 00:00:00 2001 From: ChengCan <783785929@qq.com> Date: Thu, 30 Apr 2026 00:58:07 +0800 Subject: [PATCH] feat(child): auto create parent-child conversations --- mini-program/app/dao/child.py | 4 +- mini-program/app/service/child.py | 24 ++++- mini-program/app/service/im.py | 22 ++++ mini-program/tests/test_dao.py | 154 +++++++++++++++++++++++++++ talkingq-url/banban/dao/child.py | 4 +- talkingq-url/banban/service/child.py | 21 +++- 6 files changed, 221 insertions(+), 8 deletions(-) diff --git a/mini-program/app/dao/child.py b/mini-program/app/dao/child.py index 046bf2c..314aa02 100644 --- a/mini-program/app/dao/child.py +++ b/mini-program/app/dao/child.py @@ -26,6 +26,7 @@ class ChildDAO(BaseDAO): child_name: str, child_gender: int = 2, child_birthday: Optional[date] = None, + auto_commit: bool = True, ) -> int: result = self.db.execute( text( @@ -38,7 +39,8 @@ class ChildDAO(BaseDAO): ) child_id = inserted_primary_key(result) self._create_relation(user_id, child_id) - self.commit() + if auto_commit: + self.commit() return child_id def get_by_id(self, child_id: int) -> Optional[Mapping]: diff --git a/mini-program/app/service/child.py b/mini-program/app/service/child.py index 8637995..e6038f1 100644 --- a/mini-program/app/service/child.py +++ b/mini-program/app/service/child.py @@ -3,10 +3,12 @@ from datetime import date from typing import Optional from app.dao.child import ChildDAO +from app.service.im import ensure_parent_child_conversation class ChildService: def __init__(self, db): + self.db = db self.dao = ChildDAO(db) def create( @@ -16,8 +18,24 @@ class ChildService: child_gender: int = 2, child_birthday: Optional[date] = None, ) -> Mapping: - child_id = self.dao.create(user_id, child_name, child_gender, child_birthday) - return self.dao.get_by_id(child_id) + try: + child_id = self.dao.create( + user_id, + child_name, + child_gender, + child_birthday, + auto_commit=False, + ) + ensure_parent_child_conversation( + self.db, + parent_user_id=user_id, + child_id=child_id, + ) + self.db.commit() + return self.dao.get_by_id(child_id) + except Exception: + self.db.rollback() + raise def list_children(self, user_id: int, limit: int = 20, cursor: int = None) -> tuple[list, bool]: rows = self.dao.list_by_parent(user_id, limit, cursor) @@ -39,4 +57,4 @@ class ChildService: if not self.dao.has_access(child_id, user_id): raise PermissionError("No access to this child") self.dao.update(child_id, child_name, child_gender, child_birthday) - return self.dao.get_by_id(child_id) \ No newline at end of file + return self.dao.get_by_id(child_id) diff --git a/mini-program/app/service/im.py b/mini-program/app/service/im.py index dcbd5ef..fd65870 100644 --- a/mini-program/app/service/im.py +++ b/mini-program/app/service/im.py @@ -217,6 +217,28 @@ def create_parent_child_message( ) +def ensure_parent_child_conversation( + db: Session, + *, + parent_user_id: int, + child_id: int, +) -> int: + child_row = assert_parent_child_access(db, user_id=parent_user_id, child_id=child_id) + parent_row = _get_parent_row(db, parent_user_id) + if not parent_row: + raise HTTPException(status_code=404, detail="parent not found") + + return _get_or_create_conversation( + db=db, + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=str(child_id), + participant_b_type=PARENT_PARTICIPANT_TYPE, + participant_b_id=str(parent_user_id), + pair_key=f"{child_id}:{parent_user_id}", + ) + + def create_device_message( db: Session, *, diff --git a/mini-program/tests/test_dao.py b/mini-program/tests/test_dao.py index e659b77..1eaafa1 100644 --- a/mini-program/tests/test_dao.py +++ b/mini-program/tests/test_dao.py @@ -60,6 +60,158 @@ def test_child_dao(): assert child["child_name"] == "Test Child" +def test_child_service_create_creates_parent_child_conversation(): + """Creating a child should also create a parent-child conversation.""" + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + from app.models import Base + from app.service.child import ChildService + + engine = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(bind=engine) + Session = sessionmaker(bind=engine) + db = Session() + + db.execute(text("INSERT INTO parents (openid, nickname, status) VALUES ('p_child_service', 'Parent A', 1)")) + db.commit() + user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_child_service'")).scalar_one()) + + service = ChildService(db) + child = service.create(user_id, "Kid Service") + + conversation = ( + db.execute( + text( + """ + SELECT + conversation_type, + participant_a_type, + participant_a_id, + participant_b_type, + participant_b_id, + pair_key, + status, + last_seq, + message_count + FROM im_conversations + WHERE conversation_type = 2 + AND pair_key = :pair_key + LIMIT 1 + """ + ), + {"pair_key": f"{child['child_id']}:{user_id}"}, + ) + .mappings() + .first() + ) + + assert conversation is not None + assert int(conversation["conversation_type"]) == 2 + assert int(conversation["participant_a_type"]) == 2 + assert str(conversation["participant_a_id"]) == str(child["child_id"]) + assert int(conversation["participant_b_type"]) == 1 + assert str(conversation["participant_b_id"]) == str(user_id) + assert int(conversation["status"]) == 1 + assert int(conversation["last_seq"]) == 0 + assert int(conversation["message_count"]) == 0 + + +def test_parent_child_message_reuses_precreated_conversation(): + """Parent message creation should reuse the conversation created with the child.""" + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + from app.models import Base + from app.schemas.im import ParentChildMessageCreateRequest + from app.service.child import ChildService + from app.service.im import create_parent_child_message + + engine = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(bind=engine) + Session = sessionmaker(bind=engine) + db = Session() + + db.execute(text("INSERT INTO parents (openid, nickname, status) VALUES ('p_msg_reuse', 'Parent B', 1)")) + db.commit() + user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_msg_reuse'")).scalar_one()) + + service = ChildService(db) + child = service.create(user_id, "Kid Reuse") + child_id = int(child["child_id"]) + + precreated_conversation_id = int( + db.execute( + text( + """ + SELECT id + FROM im_conversations + WHERE conversation_type = 2 + AND pair_key = :pair_key + LIMIT 1 + """ + ), + {"pair_key": f"{child_id}:{user_id}"}, + ).scalar_one() + ) + + payload = ParentChildMessageCreateRequest( + content_type=1, + content_text="hello child", + client_msg_id="parent-msg-reuse-001", + ) + result = create_parent_child_message( + db, + parent_user_id=user_id, + child_id=child_id, + payload=payload, + ) + + assert result.idempotent is False + assert result.conversation_id == precreated_conversation_id + + duplicate = create_parent_child_message( + db, + parent_user_id=user_id, + child_id=child_id, + payload=payload, + ) + assert duplicate.idempotent is True + assert duplicate.conversation_id == precreated_conversation_id + + conversation = ( + db.execute( + text( + """ + SELECT last_seq, message_count, last_message_preview + FROM im_conversations + WHERE id = :conversation_id + """ + ), + {"conversation_id": precreated_conversation_id}, + ) + .mappings() + .first() + ) + assert conversation is not None + assert int(conversation["last_seq"]) == 1 + assert int(conversation["message_count"]) == 1 + assert conversation["last_message_preview"] == "hello child" + + conversation_count = int( + db.execute( + text( + """ + SELECT COUNT(*) + FROM im_conversations + WHERE conversation_type = 2 + AND pair_key = :pair_key + """ + ), + {"pair_key": f"{child_id}:{user_id}"}, + ).scalar_one() + ) + assert conversation_count == 1 + + def test_binding_dao(): """Test BindingDAO.""" from sqlalchemy import create_engine @@ -285,6 +437,8 @@ if __name__ == "__main__": test_sqlite_connection() test_parent_dao() test_child_dao() + test_child_service_create_creates_parent_child_conversation() + test_parent_child_message_reuses_precreated_conversation() test_binding_dao() test_confirm_bind_upserts_parent_child_relation() test_direct_bind_reactivates_parent_child_relation() diff --git a/talkingq-url/banban/dao/child.py b/talkingq-url/banban/dao/child.py index 65e072e..adf3c7c 100644 --- a/talkingq-url/banban/dao/child.py +++ b/talkingq-url/banban/dao/child.py @@ -24,6 +24,7 @@ class ChildDAO(BaseDAO): child_name: str, child_gender: int = 2, child_birthday: Optional[date] = None, + auto_commit: bool = True, ) -> int: result = await self.execute( """ @@ -34,7 +35,8 @@ class ChildDAO(BaseDAO): ) child_id = inserted_primary_key(result) await self._create_relation(user_id, child_id) - await self.commit() + if auto_commit: + await self.commit() return child_id async def get_by_id(self, child_id: int) -> Optional[Mapping]: diff --git a/talkingq-url/banban/service/child.py b/talkingq-url/banban/service/child.py index 269d6d2..d304cdf 100644 --- a/talkingq-url/banban/service/child.py +++ b/talkingq-url/banban/service/child.py @@ -3,6 +3,7 @@ from datetime import date from typing import Optional from banban.dao.child import ChildDAO +from banban.dao.im import ImDAO from services.database_service_base import DatabaseServiceBase @@ -19,10 +20,24 @@ class ChildService(DatabaseServiceBase): ) -> Mapping: db_session = await self.get_session() try: - dao = ChildDAO(db_session) - child_id = await dao.create(user_id, child_name, child_gender, child_birthday) + child_dao = ChildDAO(db_session) + im_dao = ImDAO(db_session) + child_id = await child_dao.create( + user_id, + child_name, + child_gender, + child_birthday, + auto_commit=False, + ) + await im_dao.ensure_parent_child_conversation( + parent_user_id=user_id, + child_id=child_id, + ) await db_session.commit() - return await self.get(child_id) + return await child_dao.get_by_id(child_id) + except Exception: + await db_session.rollback() + raise finally: await db_session.close()