Skip to content

Commit c91cc40

Browse files
committed
refactor(core): 统一交互模式,移除旧的同步等待机制
1 parent c3c1cf2 commit c91cc40

File tree

7 files changed

+161
-124
lines changed

7 files changed

+161
-124
lines changed

core/GEMINI.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,8 @@
3030
3. **充当信号代理 (Signal Proxy)**: 这是理解其工作模式的**关键**`GameSyncWorker``ScriptWorker` 都会监听其内部 `GuiInteractionProvider` 实例发出的“内部”信号,然后**转发**一个由 Worker 自身定义的、名称相同的“外部”信号给 `MainWindow`。这确保了交互请求可以被线程安全地传递到主GUI线程进行处理。
3131
4. **提供响应入口**: 为了接收 `MainWindow` 的响应,两个 Worker 都提供了公共方法。这些方法通过 `loop.call_soon_threadsafe` 来保证响应被安全地传递回后台的 `asyncio` 事件循环。
3232

33-
- **架构说明 (待重构)**:
34-
目前项目**并存两套交互机制**,这是一个历史遗留问题和技术债:
35-
- **现代机制 (`set_interaction_response`)**: 基于 `asyncio.Future`,用于绝大多数交互,是首选方式。
36-
- **传统机制 (`set_choice`)**: 基于 `QMutex``QWaitCondition`,仅用于初次游戏选择和重复项确认。这部分逻辑未来应被重构,统一到 `asyncio.Future` 机制,以简化架构。
33+
- **架构说明**:
34+
交互完全基于 `asyncio.Future``InteractionProvider` 接口实现,确保了后台与前台之间通信模式的统一和线程安全。
3735

3836
## 3. 核心工作流(GUI模式)
3937

@@ -47,6 +45,6 @@ GUI 模式下的工作流与CLI模式在核心逻辑上相似,但在交互处
4745
6. Worker 监听到此内部信号,并立即发出一个**同名的外部信号**
4846
7. `MainWindow` 的对应槽函数被触发。
4947
8. `MainWindow` 显示对话框,等待用户操作。
50-
9. 用户操作完毕后,`MainWindow` 调用 `worker.set_interaction_response(...)` `worker.set_choice(...)` 将结果返回给 Worker。
48+
9. 用户操作完毕后,`MainWindow` 调用 `worker.set_interaction_response(...)` 将结果返回给 Worker。
5149
10. Worker 通过线程安全的方式将结果传递给 `GuiInteractionProvider`,解除 `await` 阻塞。
5250
11. 核心业务逻辑继续执行。

core/gui_worker.py

Lines changed: 28 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
class GameSyncWorker(QThread):
1616
process_completed = Signal(bool)
17+
# --- Signals to MainWindow to request showing a dialog ---
1718
selection_required = Signal(list, str, str)
1819
duplicate_check_required = Signal(list)
1920
bangumi_mapping_required = Signal(dict)
@@ -31,9 +32,6 @@ def __init__(self, keyword, manual_mode=False, parent=None, shared_context=None)
3132
self.manual_mode = manual_mode
3233
self.shared_context = shared_context
3334
self.context = {}
34-
self.mutex = QMutex()
35-
self.wait_condition = QWaitCondition()
36-
self.user_choice = None
3735
self.interaction_provider = None
3836
self.loop = None
3937

@@ -55,19 +53,20 @@ async def setup_context():
5553
self.context = {**self.shared_context, **loop_specific_context}
5654

5755
try:
58-
# Run setup first to create the provider
5956
self.loop.run_until_complete(setup_context())
6057

61-
# Then connect signals
58+
# Connect all interaction signals from the provider to the worker's proxy slots
6259
self.interaction_provider.handle_new_bangumi_key_requested.connect(self._on_bangumi_mapping_requested)
6360
self.interaction_provider.ask_for_new_property_type_requested.connect(self._on_property_type_requested)
6461
self.interaction_provider.select_bangumi_game_requested.connect(self._on_bangumi_selection_requested)
6562
self.interaction_provider.tag_translation_required.connect(self._on_tag_translation_requested)
6663
self.interaction_provider.concept_merge_required.connect(self._on_concept_merge_requested)
6764
self.interaction_provider.name_split_decision_required.connect(self._on_name_split_decision_requested)
6865
self.interaction_provider.confirm_brand_merge_requested.connect(self._on_brand_merge_requested)
66+
# --- Newly refactored signal connections ---
67+
self.interaction_provider.select_game_requested.connect(self._on_select_game_requested)
68+
self.interaction_provider.duplicate_check_requested.connect(self._on_duplicate_check_requested)
6969

70-
# Now run the main game flow
7170
self.loop.run_until_complete(self.game_flow())
7271

7372
except Exception as e:
@@ -77,18 +76,14 @@ async def setup_context():
7776
finally:
7877
if self.interaction_provider:
7978
try:
80-
self.interaction_provider.handle_new_bangumi_key_requested.disconnect(self._on_bangumi_mapping_requested)
81-
self.interaction_provider.ask_for_new_property_type_requested.disconnect(self._on_property_type_requested)
82-
self.interaction_provider.select_bangumi_game_requested.disconnect(self._on_bangumi_selection_requested)
83-
self.interaction_provider.tag_translation_required.disconnect(self._on_tag_translation_requested)
84-
self.interaction_provider.concept_merge_required.disconnect(self._on_concept_merge_requested)
85-
self.interaction_provider.name_split_decision_required.disconnect(self._on_name_split_decision_requested)
86-
self.interaction_provider.confirm_brand_merge_requested.disconnect(self._on_brand_merge_requested)
87-
except RuntimeError:
79+
# Disconnect all signals
80+
for signal_name in dir(self.interaction_provider):
81+
if isinstance(getattr(self.interaction_provider, signal_name), Signal):
82+
getattr(self.interaction_provider, signal_name).disconnect()
83+
except (RuntimeError, TypeError):
8884
pass
8985

9086
async def cleanup_tasks():
91-
# Cancel background tasks first
9287
background_tasks = self.context.get("background_tasks", [])
9388
if background_tasks:
9489
logger.system(f"正在取消 {len(background_tasks)} 个后台任务...")
@@ -97,7 +92,6 @@ async def cleanup_tasks():
9792
await asyncio.gather(*background_tasks, return_exceptions=True)
9893
logger.system("所有后台任务已处理。")
9994

100-
# Close HTTP client
10195
if self.context.get("async_client"):
10296
await self.context["async_client"].aclose()
10397
logger.system("线程内HTTP客户端已关闭。")
@@ -107,6 +101,7 @@ async def cleanup_tasks():
107101

108102
self.loop.close()
109103

104+
# --- Proxy slots to forward signals from InteractionProvider to MainWindow ---
110105
def _on_bangumi_mapping_requested(self, request_data):
111106
self.bangumi_mapping_required.emit(request_data)
112107

@@ -128,40 +123,18 @@ def _on_name_split_decision_requested(self, text, parts):
128123
def _on_brand_merge_requested(self, new_brand_name, suggested_brand):
129124
self.confirm_brand_merge_requested.emit(new_brand_name, suggested_brand)
130125

126+
def _on_select_game_requested(self, choices, title, source):
127+
self.selection_required.emit(choices, title, source)
128+
129+
def _on_duplicate_check_requested(self, candidates):
130+
self.duplicate_check_required.emit(candidates)
131+
132+
# --- Method for MainWindow to send response back ---
131133
def set_interaction_response(self, response):
132134
if self.loop and self.interaction_provider:
133135
self.loop.call_soon_threadsafe(self.interaction_provider.set_response, response)
134136

135-
def set_choice(self, choice):
136-
self.mutex.lock()
137-
self.user_choice = choice
138-
self.mutex.unlock()
139-
self.wait_condition.wakeAll()
140-
141-
async def wait_for_choice(self, choices: list, title: str, source: str = ""):
142-
# 1. 先发射信号,此时不持有任何锁,避免死锁
143-
if source:
144-
self.selection_required.emit(choices, title, source)
145-
else:
146-
self.duplicate_check_required.emit(choices)
147-
148-
# 2. 现在获取锁,并等待主线程的响应
149-
self.mutex.lock()
150-
try:
151-
# 为等待用户输入设置60秒超时
152-
timed_out = not self.wait_condition.wait(self.mutex, 60000)
153-
if timed_out and self.user_choice is None:
154-
logger.warn("等待用户选择超时(60秒),将自动执行‘跳过’操作。")
155-
choice = "skip"
156-
else:
157-
choice = self.user_choice
158-
finally:
159-
# 3. 重置选择并解锁,为下一次交互做准备
160-
self.user_choice = None
161-
self.mutex.unlock()
162-
163-
return choice
164-
137+
# --- Core async logic ---
165138
async def _select_game_from_results(self, results, source):
166139
game = None
167140
while True:
@@ -178,7 +151,9 @@ async def _select_game_from_results(self, results, source):
178151
logger.info(f"智能模式匹配度 ({best_score:.2f}) 过低,转为手动选择。")
179152

180153
if game is None:
181-
choice = await self.wait_for_choice(results, f"请从 {source.upper()} 结果中选择", source)
154+
# REFACTORED: Call the provider instead of wait_for_choice
155+
choice = await self.interaction_provider.select_game(results, f"请从 {source.upper()} 结果中选择", source)
156+
182157
if choice == "search_fanza":
183158
logger.info("切换到 Fanza 搜索...")
184159
results, source = await search_all_sites(self.context["dlsite"], self.context["fanza"], self.keyword, site="fanza")
@@ -198,7 +173,9 @@ async def _check_for_duplicates(self, title):
198173
if not candidates:
199174
return None
200175

201-
choice = await self.wait_for_choice(candidates, "发现重复游戏")
176+
# REFACTORED: Call the provider instead of wait_for_choice
177+
choice = await self.interaction_provider.confirm_duplicate(candidates)
178+
202179
if choice == "skip":
203180
logger.info("已选择跳过。")
204181
return "skip"
@@ -209,7 +186,7 @@ async def _check_for_duplicates(self, title):
209186
elif choice == "create":
210187
logger.info("已选择强制创建新游戏。")
211188
return None
212-
return None
189+
return None # Default to cancel
213190

214191
async def _fetch_ggbases_data(self, keyword, manual_mode):
215192
logger.info("[GGBases] 开始获取 GGBases 数据...")
@@ -222,7 +199,8 @@ async def _fetch_ggbases_data(self, keyword, manual_mode):
222199
selected_game = None
223200
if manual_mode:
224201
logger.info("[GGBases] 手动模式,需要用户选择。")
225-
choice = await self.wait_for_choice(candidates, "请从GGBases结果中选择", "ggbases")
202+
# REFACTORED: Call the provider instead of wait_for_choice
203+
choice = await self.interaction_provider.select_game(candidates, "请从GGBases结果中选择", "ggbases")
226204
if isinstance(choice, int) and choice != -1:
227205
selected_game = candidates[choice]
228206
else:

core/interaction.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ async def confirm_brand_merge(self, new_brand_name: str, suggested_brand: str) -
4242
"""当发现一个新品牌与一个现有品牌高度相似时,询问用户如何操作。"""
4343
pass
4444

45+
@abstractmethod
46+
async def select_game(self, choices: list, title: str, source: str) -> int | str | None:
47+
"""
48+
要求用户从搜索结果列表中选择一个游戏。
49+
也处理特定于源的选项,如“切换到Fanza搜索”。
50+
返回选择的索引、特殊操作字符串或None。
51+
"""
52+
pass
53+
54+
@abstractmethod
55+
async def confirm_duplicate(self, candidates: list) -> str | None:
56+
"""
57+
显示潜在的重复游戏,并询问用户是跳过、更新还是强制创建。
58+
返回 'skip', 'update', 'create' 或 None。
59+
"""
60+
pass
61+
4562

4663
class ConsoleInteractionProvider(InteractionProvider):
4764
"""Console implementation for user interaction using input()."""
@@ -226,6 +243,69 @@ def _get_input():
226243
return {"action": "keep", "save_exception": True}
227244
return {"action": "keep", "save_exception": False}
228245

246+
async def select_game(self, choices: list, title: str, source: str) -> int | str | None:
247+
"""要求用户从搜索结果列表中选择一个游戏。"""
248+
def _get_input():
249+
logger.info(title)
250+
if source == 'ggbases':
251+
for i, item in enumerate(choices):
252+
size_info = item.get('容量', '未知')
253+
popularity = item.get('popularity', 0)
254+
print(f" [{i+1}] {item.get('title', 'No Title')} (热度: {popularity}) (大小: {size_info})")
255+
else:
256+
for i, item in enumerate(choices):
257+
price = item.get("价格") or item.get("price", "未知")
258+
price_display = f"{price}円" if str(price).isdigit() else price
259+
item_type = item.get("类型", "未知")
260+
print(f" [{i+1}] [{source.upper()}] {item.get('title', 'No Title')} | 💴 {price_display} | 🏷️ {item_type}")
261+
262+
prompt = "\n请输入序号进行选择 (0 放弃"
263+
if source == 'dlsite':
264+
prompt += ", f 切换到Fanza搜索"
265+
prompt += "): "
266+
return input(prompt).strip().lower()
267+
268+
while True:
269+
choice = await asyncio.to_thread(_get_input)
270+
if choice == 'f' and source == 'dlsite':
271+
logger.info("切换到 Fanza 搜索...")
272+
return "search_fanza"
273+
if choice == '0':
274+
logger.info("用户取消了选择。")
275+
return -1
276+
try:
277+
choice_idx = int(choice) - 1
278+
if 0 <= choice_idx < len(choices):
279+
return choice_idx
280+
else:
281+
logger.error("无效的序号,请重新输入。")
282+
except ValueError:
283+
logger.error("无效输入,请输入数字或指定字母。")
284+
285+
async def confirm_duplicate(self, candidates: list) -> str | None:
286+
"""显示潜在的重复游戏,并询问用户如何处理。"""
287+
def _get_input():
288+
logger.warn("发现可能重复的游戏,请选择操作:")
289+
for i, (game, similarity) in enumerate(candidates):
290+
title = game.get("title", "未知标题")
291+
print(f" - 相似条目: {title} (相似度: {similarity:.2f})")
292+
293+
print("\n [s] 跳过,不处理此游戏 (默认)")
294+
print(" [u] 更新最相似的已有条目")
295+
print(" [c] 强制创建为新条目")
296+
return input("请输入您的选择 (s/u/c): ").strip().lower()
297+
298+
while True:
299+
choice = await asyncio.to_thread(_get_input)
300+
if choice in {'s', ''}:
301+
return "skip"
302+
elif choice == 'u':
303+
return "update"
304+
elif choice == 'c':
305+
return "create"
306+
else:
307+
logger.error("无效输入,请重新选择。")
308+
229309
# This will be implemented in a separate file to avoid circular dependencies with GUI components
230310
# class GuiInteractionProvider(InteractionProvider):
231311
# ...

0 commit comments

Comments
 (0)