diff --git a/application/world/services/bible_service.py b/application/world/services/bible_service.py index 8970beebb..72258bead 100644 --- a/application/world/services/bible_service.py +++ b/application/world/services/bible_service.py @@ -13,7 +13,7 @@ from domain.bible.repositories.bible_repository import BibleRepository from domain.novel.repositories.novel_repository import NovelRepository from domain.novel.repositories.chapter_repository import ChapterRepository -from domain.shared.exceptions import EntityNotFoundError +from domain.shared.exceptions import EntityNotFoundError, InvalidOperationError from application.world.dtos.bible_dto import BibleDTO, CharacterDTO if TYPE_CHECKING: @@ -105,7 +105,7 @@ def add_character( description: str, relationships: list = None ) -> BibleDTO: - """添加人物 + """添加人物(增量写入,不触碰已有角色和关系) Args: novel_id: 小说 ID @@ -124,15 +124,41 @@ def add_character( if bible is None: raise EntityNotFoundError("Bible", f"for novel {novel_id}") - character = Character( - id=CharacterId(character_id), - name=name, - description=description, - relationships=relationships or [], - ) - bible.add_character(character) - self.bible_repository.save(bible) + # 检查角色是否已存在 + if bible.get_character(CharacterId(character_id)) is not None: + raise InvalidOperationError( + f"Character with id '{character_id}' already exists" + ) + # 增量写入:只 INSERT 新角色及其关系,不 DELETE 其他数据 + repo = self.bible_repository + if hasattr(repo, "add_character_incremental"): + rel_dicts = [] + for rel in (relationships or []): + if isinstance(rel, dict): + rel_dicts.append(rel) + elif hasattr(rel, "model_dump"): + rel_dicts.append(rel.model_dump()) + elif hasattr(rel, "dict"): + rel_dicts.append(rel.dict()) + else: + rel_dicts.append({"target": str(rel), "relation": "", "description": ""}) + repo.add_character_incremental( + novel_id, character_id, name, description, rel_dicts, + ) + else: + # fallback:非 SQLite 仓储走原路径 + character = Character( + id=CharacterId(character_id), + name=name, + description=description, + relationships=relationships or [], + ) + bible.add_character(character) + repo.save(bible) + + # 重新加载最新状态返回 + bible = self.bible_repository.get_by_novel_id(NovelId(novel_id)) return BibleDTO.from_domain(bible) def update_character_voice_anchors( diff --git a/infrastructure/persistence/database/sqlite_bible_repository.py b/infrastructure/persistence/database/sqlite_bible_repository.py index 52fffb590..b2c4cdb79 100644 --- a/infrastructure/persistence/database/sqlite_bible_repository.py +++ b/infrastructure/persistence/database/sqlite_bible_repository.py @@ -315,6 +315,51 @@ def exists(self, bible_id: str) -> bool: r = self.db.fetch_one("SELECT 1 AS o FROM bibles WHERE id = ?", (bible_id,)) return r is not None + def add_character_incremental( + self, + novel_id: str, + character_id: str, + name: str, + description: str, + relationships: List[Dict[str, str]], + *, + mental_state: str = "NORMAL", + verbal_tic: str = "", + idle_behavior: str = "", + ) -> None: + """增量添加单个角色及其关系(不触碰其他角色)。""" + now = self._now() + conn = self._conn() + try: + conn.execute( + """ + INSERT OR REPLACE INTO bible_characters ( + id, novel_id, name, description, + mental_state, mental_state_reason, verbal_tic, idle_behavior, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, '', ?, ?, ?, ?) + """, + (character_id, novel_id, name, description, + mental_state, verbal_tic, idle_behavior, now, now), + ) + for i, rel in enumerate(relationships): + rid = f"{character_id}-rel-{i}-{uuid.uuid4().hex[:6]}" + target_name = rel.get("target", "") or "" + relation = rel.get("relation", "") or "" + desc = rel.get("description", "") or "" + conn.execute( + """ + INSERT INTO bible_character_relationships + (id, character_id, target_name, relation, description) + VALUES (?, ?, ?, ?, ?) + """, + (rid, character_id, target_name, relation, desc), + ) + conn.commit() + except Exception: + conn.rollback() + raise + def update_character_anchors( self, novel_id: str, diff --git a/tests/unit/application/services/test_bible_service.py b/tests/unit/application/services/test_bible_service.py index 9813335aa..dac071cda 100644 --- a/tests/unit/application/services/test_bible_service.py +++ b/tests/unit/application/services/test_bible_service.py @@ -40,9 +40,13 @@ def test_create_bible(self, service, mock_repository): def test_add_character(self, service, mock_repository): """测试添加人物""" - # 准备 mock 数据 - bible = Bible(id="bible-1", novel_id=NovelId("novel-1")) - mock_repository.get_by_novel_id.return_value = bible + # 准备 mock 数据:第一次返回空 Bible,第二次返回含角色的 Bible(模拟增量写入后) + bible_empty = Bible(id="bible-1", novel_id=NovelId("novel-1")) + bible_with_char = Bible(id="bible-1", novel_id=NovelId("novel-1")) + bible_with_char.add_character( + Character(id=CharacterId("char-1"), name="主角", description="主角描述") + ) + mock_repository.get_by_novel_id.side_effect = [bible_empty, bible_with_char] bible_dto = service.add_character( novel_id="novel-1", @@ -56,8 +60,8 @@ def test_add_character(self, service, mock_repository): assert bible_dto.characters[0].id == "char-1" assert bible_dto.characters[0].name == "主角" - # 验证调用了 save - mock_repository.save.assert_called_once() + # 验证调用了增量写入 + mock_repository.add_character_incremental.assert_called_once() def test_add_character_bible_not_found(self, service, mock_repository): """测试向不存在的 Bible 添加人物"""