Skip to content

Commit dadfa23

Browse files
authored
[1009] 修复单个模板点击取消后重复点击会额外触发下载框的问题 (#3370)
1 parent 4605833 commit dadfa23

5 files changed

Lines changed: 1007 additions & 12 deletions

File tree

devel/1009.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# [1009] 修复单个模板点击取消后重复点击会额外触发下载框的问题
2+
3+
## 1 相关文档
4+
- [dddd.md](dddd.md) - 任务文档模板
5+
6+
## 2 任务相关的代码文件
7+
- `src/Mogan/TemplateCenter/template_api.hpp`
8+
- `src/Mogan/TemplateCenter/template_api.cpp`
9+
10+
## 3 如何测试
11+
12+
### 确定性测试(单元测试)
13+
```bash
14+
./bin/test_only template_api_test
15+
```
16+
17+
### 非确定性测试(功能验证)
18+
19+
1. 启动 Mogan,进入 Template Center
20+
2. 点击一个**未缓存**的模板卡片 → 弹出下载进度对话框
21+
3. 点击进度对话框的 **Cancel** 按钮 → 对话框应关闭
22+
4. **立即再次点击同一个模板** → 应重新弹出下载进度对话框
23+
5. 等待下载完成 → 模板应正常打开,**不应出现多个下载框**
24+
6. 重复步骤 2-5 多次(取消 3~5 次后再下载)→ 下载完成时应**只打开一次文档**
25+
26+
### 3.2 边界场景
27+
28+
- 取消下载后快速切换到其他模板再切回 → 不应有残留下载影响新模板
29+
- 取消下载后断网再点击 → 应正常提示下载失败,不出现残留状态
30+
31+
## 4 如何提交
32+
33+
提交前执行以下最少步骤:
34+
35+
```bash
36+
# 编译验证
37+
xmake build
38+
```
39+
40+
## 5 What
41+
42+
修复模板下载过程中点击取消后,重新下载同一个模板会导致下载框和文档打开次数"每次递增"的 bug。
43+
44+
1. **`TemplateAPI` 新增 `abortDownload()` 私有方法**
45+
- 静默终止指定模板的网络请求,清理 `downloadReplies_`
46+
- ****发射任何信号,仅用于内部清理
47+
48+
2. **`downloadTemplate()` 改调 `abortDownload()` 清理旧请求**
49+
- 启动新下载前,用 `abortDownload` 终止同一模板的旧请求
50+
- 避免旧请求的信号干扰新下载流程
51+
52+
3. **`cancelDownload()` 清理后 emit `downloadFailed`**
53+
- 用户点击 Cancel 时,终止请求并发射 `downloadFailed(templateId, "Download cancelled")`
54+
- 确保 `TemplateManager::downloadTemplateSync()` 中的 `QEventLoop` 立即退出
55+
56+
4. **`onDownloadFinished()` 补充 HTTP status code 检查**
57+
- 原实现仅检查 `reply->error()`,当服务器返回 HTTP 404 但网络传输成功时,`QNetworkReply::error()` 可能仍为 `NoError`
58+
- 增加对 `HttpStatusCodeAttribute` 的检查,将 `>= 400` 的 HTTP 响应统一视为下载失败
59+
60+
## 6 Why
61+
62+
### 6.1 现象
63+
64+
用户在下载模板时点击取消,再次点击同一模板重新下载,下载完成后:
65+
- 弹出多个(随取消次数递增)下载完成提示
66+
- 同一文档被多次打开
67+
68+
### 6.2 根因
69+
70+
`TemplateAPI::cancelDownload()` 的实现顺序有问题:
71+
72+
```cpp
73+
void TemplateAPI::cancelDownload(const QString& templateId) {
74+
auto it = downloadReplies_.find(templateId);
75+
if (it != downloadReplies_.end() && it.value()) {
76+
disconnect(it.value(), nullptr, this, nullptr); // 先断开信号
77+
it.value()->abort(); // 再 abort
78+
it.value()->deleteLater();
79+
downloadReplies_.erase(it);
80+
}
81+
}
82+
```
83+
84+
- `disconnect` 后调用 `abort()`,`QNetworkReply::finished` 信号发出时,`onDownloadFinished` 已收不到
85+
- `downloadFailed` 信号**永远不会被 emit**
86+
- `TemplateManager::downloadTemplateSync()` 中的 `QEventLoop` 收不到 `downloadCompleted` 或 `downloadFailed`,只能傻等 30 秒超时
87+
88+
在这段超时期间:
89+
- 旧的调用 `downloadTemplateSync()` 的 UI 层对象仍然卡在 `loop.exec()` 中
90+
- 旧对象仍然连接着 `TemplateManager` 的 `downloadCompleted` / `downloadFailed` 信号
91+
- 用户点击"重新下载"后,新的下载完成时会 emit `downloadCompleted`
92+
- **新旧所有卡住的对象都会收到该信号**,导致模板被多次打开
93+
94+
取消 N 次就会累积 N 个"幽灵对象",下次成功下载时同时触发 N+1 次完成处理。
95+
96+
## 7 How
97+
98+
### 7.1 信号缺失问题
99+
100+
`cancelDownload` 需要 emit `downloadFailed`,让 `downloadTemplateSync` 的 `QEventLoop` 立即退出。但 `downloadTemplate()` 内部在启动新下载前也会调用 `cancelDownload` 清理旧请求,此时 emit `downloadFailed` 会误伤新启动的下载。
101+
102+
### 7.2 拆分为两个方法
103+
104+
| 方法 | 触发场景 | 是否 emit 信号 |
105+
|------|----------|----------------|
106+
| `abortDownload` | `downloadTemplate` 内部启动新下载前 | 否 |
107+
| `cancelDownload` | 用户点击 Cancel 按钮 | 是,emit `downloadFailed` |
108+
109+
```cpp
110+
void TemplateAPI::abortDownload(const QString& templateId) {
111+
auto it = downloadReplies_.find(templateId);
112+
if (it != downloadReplies_.end() && it.value()) {
113+
disconnect(it.value(), nullptr, this, nullptr);
114+
it.value()->abort();
115+
it.value()->deleteLater();
116+
downloadReplies_.erase(it);
117+
}
118+
}
119+
120+
void TemplateAPI::cancelDownload(const QString& templateId) {
121+
auto it = downloadReplies_.find(templateId);
122+
if (it != downloadReplies_.end() && it.value()) {
123+
disconnect(it.value(), nullptr, this, nullptr);
124+
it.value()->abort();
125+
it.value()->deleteLater();
126+
downloadReplies_.erase(it);
127+
emit downloadFailed(templateId, tr("Download cancelled"));
128+
}
129+
}
130+
```
131+
132+
### 7.3 调用链路
133+
134+
**用户取消下载:**
135+
> 注:`QTMTemplateOpener` 是本文档中的概念化表述,对应实际代码中调用
136+
> `TemplateManager::downloadTemplateSync()` 的 UI 层对象(如启动页的模板卡片打开逻辑)。
137+
138+
```
139+
QProgressDialog::canceled
140+
→ UI 层对象::cancelDownload (templateId)
141+
→ TemplateManager::cancelDownload (templateId)
142+
→ TemplateAPI::cancelDownload (templateId) [emit downloadFailed]
143+
→ TemplateManager::downloadFailed
144+
→ downloadTemplateSync::QEventLoop::quit [立即退出]
145+
```
146+
147+
**重新下载(启动新请求前清理旧请求):**
148+
```
149+
UI 层对象::openTemplate
150+
→ TemplateManager::downloadTemplateSync
151+
→ TemplateManager::downloadTemplate
152+
→ TemplateAPI::downloadTemplate
153+
→ TemplateAPI::abortDownload (templateId) [静默清理,不 emit]
154+
```
155+
156+
### 7.4 修复后行为
157+
158+
- 用户点击取消 → `cancelDownload` emit `downloadFailed``QEventLoop` 立刻退出 → 调用 `downloadTemplateSync()` 的 UI 层对象立即析构,不残留
159+
- 重新下载同一个模板时,旧的对象已彻底销毁,不会"幽灵"接收新下载的完成信号
160+
- `downloadTemplate` 内部调用 `abortDownload` 清理旧请求,不会误 emit `downloadFailed` 导致新下载被中断

src/Mogan/TemplateCenter/template_api.cpp

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,10 @@ TemplateAPI::setMetadataEtag (const QString& etag) {
3535
}
3636

3737
TemplateAPI::~TemplateAPI () {
38-
// Cancel all active downloads
39-
for (auto reply : downloadReplies_) {
40-
if (!reply) continue;
41-
disconnect (reply, nullptr, this, nullptr);
42-
reply->abort ();
43-
reply->deleteLater ();
38+
// Abort all active downloads (reuses abortDownload for consistency)
39+
while (!downloadReplies_.isEmpty ()) {
40+
abortDownload (downloadReplies_.begin ().key ());
4441
}
45-
downloadReplies_.clear ();
4642

4743
if (metadataReply_) {
4844
disconnect (metadataReply_, nullptr, this, nullptr);
@@ -95,8 +91,8 @@ TemplateAPI::downloadTemplate (const QString& templateId,
9591
return;
9692
}
9793

98-
// Cancel any existing download for this template
99-
cancelDownload (templateId);
94+
// Abort any existing download for this template (internal cleanup, no signal)
95+
abortDownload (templateId);
10096

10197
QNetworkRequest request{QUrl (downloadUrl)};
10298
setupRequestHeaders (request);
@@ -114,14 +110,28 @@ TemplateAPI::downloadTemplate (const QString& templateId,
114110
&TemplateAPI::onDownloadProgress);
115111
}
116112

117-
void
118-
TemplateAPI::cancelDownload (const QString& templateId) {
113+
bool
114+
TemplateAPI::abortAndRemoveReply (const QString& templateId) {
119115
auto it= downloadReplies_.find (templateId);
120116
if (it != downloadReplies_.end () && it.value ()) {
121117
disconnect (it.value (), nullptr, this, nullptr);
122118
it.value ()->abort ();
123119
it.value ()->deleteLater ();
124120
downloadReplies_.erase (it);
121+
return true;
122+
}
123+
return false;
124+
}
125+
126+
void
127+
TemplateAPI::abortDownload (const QString& templateId) {
128+
abortAndRemoveReply (templateId);
129+
}
130+
131+
void
132+
TemplateAPI::cancelDownload (const QString& templateId) {
133+
if (abortAndRemoveReply (templateId)) {
134+
emit downloadFailed (templateId, tr ("Download cancelled"));
125135
}
126136
}
127137

@@ -205,6 +215,16 @@ TemplateAPI::onDownloadFinished () {
205215
return;
206216
}
207217

218+
// Check HTTP status code (e.g., 404 may not trigger QNetworkReply error)
219+
int httpStatus=
220+
reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toInt ();
221+
if (httpStatus >= 400) {
222+
emit downloadFailed (templateId,
223+
tr ("Download failed: HTTP %1").arg (httpStatus));
224+
reply->deleteLater ();
225+
return;
226+
}
227+
208228
// Ensure target directory exists
209229
QDir dir (QFileInfo (targetPath).path ());
210230
if (!dir.exists ()) {

src/Mogan/TemplateCenter/template_api.hpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ class TemplateAPI : public QObject {
4949
void fetchMetadata ();
5050
void downloadTemplate (const QString& templateId, const QString& downloadUrl,
5151
const QString& targetPath);
52+
53+
/**
54+
* @brief 终止下载并发射 downloadFailed(用户点击取消)。
55+
*
56+
* 用户在进度对话框中点击 Cancel 时调用。
57+
* 发射 downloadFailed(templateId, "Download cancelled"),
58+
* 使 TemplateManager::downloadTemplateSync() 中的 QEventLoop 立即退出。
59+
*/
5260
void cancelDownload (const QString& templateId);
5361

5462
// Metadata ETag for conditional requests
@@ -99,6 +107,18 @@ private slots:
99107
// Request management
100108
void setupRequestHeaders (QNetworkRequest& request);
101109

110+
/**
111+
* @brief 静默终止下载,不发射任何信号(内部使用)。
112+
*
113+
* 在 downloadTemplate() 为同一 templateId 启动新请求前调用。
114+
* 安静地终止旧的 QNetworkReply 并清理 downloadReplies_,
115+
* 但不发射 downloadFailed,避免打断新下载流程。
116+
*/
117+
void abortDownload (const QString& templateId);
118+
119+
private:
120+
bool abortAndRemoveReply (const QString& templateId);
121+
102122
private:
103123
// API configuration
104124
QString apiBaseUrl_;

src/Mogan/TemplateCenter/template_manager.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,8 @@ TemplateManager::downloadTemplateSync (const QString& templateId, int timeoutMs,
390390
connect (&timer, &QTimer::timeout, [&] () {
391391
if (finished) return;
392392
errorStr= tr ("Download timed out");
393-
cancelDownload (templateId);
394393
finished= true;
394+
cancelDownload (templateId);
395395
loop.quit ();
396396
});
397397
timer.start (timeoutMs);

0 commit comments

Comments
 (0)