Commit Graph

2 Commits

Author SHA1 Message Date
25e7d8f3bf 跨端: 补附件区(两端一直都有这个功能,我上次误判成"死代码")+ 修两个真 bug
══ ① 更正我 2026-09-19 的一个**错误结论**(已写进 docs/DEBTS.json 留档)

那天我审计后写下:`GET /me/mail/inbox` 的回包**既没有 `attachments` 也没有
`has_attachments`** ⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0`
恒为 0、WebUI 那个 📎 是**死代码**。并据此在鸿蒙侧**有意不抄**这个标记。

**这个结论是错的**,错在取证方法:我**只看了一封没有附件的邮件**,
看到 key 不在,就断言服务端从不返回它。事实:
· 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,注释还写着
  理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」
· `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ **没有附件的邮件根本不输出这个 key**

实测 `limit=200`(96 封):带 `attachments` 的 **2 封**,正是真有附件那两封。

★ 教训:**`omitempty` 字段的"缺失"不等于"服务端不返回"**。
  判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。
  这与同一天那个白屏崩溃(`session_workspace` 缺失 → `undefined` → 抛)
  是**同一个坑的两面** —— 那天是"缺失 → 客户端崩",今天是"缺失 → 我误判成不返回"。

══ ② 补附件区(鸿蒙原来完全没有)

· `model/Attachment.ts`(新)—— `formatSize` / `attachmentLabel`,纯逻辑无 SDK 依赖,
  逐字对齐 WebUI `api/client.ts:390`(三档 + 保留一位小数)。
· `MailApi.downloadAttachment` —— 走 `getBytes`(不是 `get<T>`:后者假定 JSON,
  取二进制会炸;壁纸当初踩过)。
· `IcsFile.saveBinaryFile` —— 与既有 `saveIcsText` 同一套流程,只是写 `ArrayBuffer`。
· `MailDetailPage` 正文之后渲染附件清单(回形针 + 文件名 + 大小 + 下载),
  位置/形态对齐 WebUI `Attachments.tsx`(无附件时**整块不渲染**)。
· 列表行的 📎 + 数字(`attach_count`,与 `cc_count` 同形状派生)。

══ ③ 顺带撞出并修掉两个**真 bug**

**bug A(差一点就是 94/96 必崩)**:我第一版写 `mail.attachments.length` ——
而 `Attachments` 带 `omitempty`,96 封里只有 2 封有这个 key ⇒ 其余 94 封是
`undefined` ⇒ `.length` 抛。**与当天早些时候那个白屏崩溃是同一个坑,
我刚修过、还在 `Models.ets` 里写了一大段注释,然后加新字段时照踩。**
⇒ 说明"记住别这么写"不管用,要在每个真正读的地方把 `?? []` 写出来。

**bug B(潜在白屏)**:`PermissionTab` 读 `req.session_alias` ——
而服务端 `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`),
`ListPendingPermissionsFor` 的 SELECT 也没查它,WebUI 的类型里同样没有。
它是我照"授权卡总得显示会话名"的直觉加出来的。⇒ 恒 `undefined`,
一旦有待办就抛。**一直没暴露只因为当前待办数一直是 0**(实测 `{"requests":[]}`)。
⇒ 改成服务端确实有的 `agent_name`,并**删掉那个字段声明**:
让误用变成**编译错**,而不是运行时白屏。

同样是 `body_preview`(`omitempty`,值是 `Body` 的截断)——
空正文 ⇒ 空串 ⇒ 服务端省略 key ⇒ `undefined.length` 抛。那批 96 封恰好都有正文,
所以"看起来没问题"——那正是这个坑的形态。已加 `?? ''`。

══ ④ 新判据:`omitempty` 字段的读法(形状,不是实例)

从 `server/internal/models` **算出**"只以 omitempty 形式出现过"的字段名
(不在判据里手抄名单),再扫鸿蒙侧对它们的裸成员调用。
★ 关键:**不能按字段名一刀切** —— 我第一版就是这么写的,报了 6 处、4 处误报:
  `session_alias` 在服务端有**两个**声明(`Mail` 上带 omitempty、`repo.Contact` 上不带),
  鸿蒙那 4 处读的全是 `Contact` ⇒ 恒有值、不是 bug。
  ⇒ 只扫"从未不带 omitempty 出现过"的名字,那 4 处自动排除。
已逐个核实 4 处豁免(每条都写了取证理由,不是"看着像就放过")。
变异验证:把 `?? []` 去掉 → 判据转红,且**正是**报 `MailStore.ets: mail.attach_count = mail.attachments.length`。

══ ⑤ 数据路径已实测(模拟器)

临时把 `INBOX_PAGE_SIZE` 提到 200(因为有附件那两封在下标 51/52,
默认 limit=50 **根本取不到** —— 这也解释了为什么之前一直没发现),
加临时 hilog 后拿到:
    AttProbe: mail=531a1629-… attach=1
    AttProbe: mail=b68cbbe8-… attach=1
正好是那两封。验完已撤掉探针、`INBOX_PAGE_SIZE` 恢复 50。

══ ⚠️ 本轮**未能**完成设备端视觉验收

模拟器已卡死(`hdc` 能连上但 `shell` 超时;进程 152% CPU、已跑 32 小时),
导致套件里的设备判据各跑 836 秒后失败("要能拉起应用")。
主机可用内存只剩 ~3.7GB。附件区的**渲染**(清单外观、下载落盘)
尚未在设备上看过 —— 待模拟器恢复后补。
2026-09-20 21:14:20 +08:00
bcd7e7f217 跨端: 鸿蒙日历补 .ics 导入导出(WebUI 有、鸿蒙一直没有)
WebUI 工具条那两个入口(`CalendarView` 的导入/导出图标)鸿蒙侧一直缺,
`CalendarPage` 的文件头也一直诚实写着"没做:…….ics 导入导出"。

## 为什么不能照抄 WebUI 的做法

WebUI 在浏览器里:导出是 `Blob` + `<a download>`、导入是 `<input type=file>`,
两条都由浏览器提供。鸿蒙**没有"下载目录"这个概念**,必须显式走系统
`DocumentViewPicker` —— 这不是多此一举,是这个平台上唯一能让用户拿到/指定文件的路。
新增 `common/IcsFile.ets` 封装选/读/写(`pickIcsText` / `saveIcsText`)。

## ApiClient 加两个方法(不能复用 `request<T>`)

`request<T>` 无条件 `JSON.parse(response.result)`,而 iCalendar 不是 JSON
(与 `getBytes` 取壁纸二进制同一个理由)。所以:
- `getText()`:`expectDataType: STRING` 取原文;
- `postText()`:raw body + **显式** `Content-Type: text/calendar`
  —— 服务端是**按 Content-Type 分流**的(`strings.HasPrefix(ct, "multipart/")`,
  否则当 raw text)。写错成 `application/json` 会走对分支但语义不对。

## 三处对齐 WebUI 的细节

- **区间用屏幕上正在看的那段**(`rangeFrom/rangeTo`),不写死 ±1 年 ——
  服务端注释里写着同一条理由:「写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件」。
- **文件名带日期**:`agentmail-<selectedIso>.ics`(WebUI 是
  `agentmail-${dayKey(anchor)}.ics`)。固定名的问题是连导两次就分不清哪份是哪份,
  而导出天然会被重复做(看一个月导一份)。实测截图里默认文件名
  `agentmail-2026-09-18.ics`。
- **`imported` 与 `skipped` 两个数都报**:只说"导入成功"会把
  "20 条里跳过了 18 条"读成一切正常,而"跳过"正是用户需要知道的那部分。

## 结果要分三种说(取消不是失败)

导出成功(给出落盘路径)/ 用户取消(**什么都不说**)/ 真失败(说原因)。
把"取消"弹成"导出失败"是"用户什么也没做却被骂一句"。
导入成功后**重拉当前区间** —— 不重拉用户看不到刚导进来的东西,会以为失败。

## 验证

- 服务端两端点实测(curl):导出 8 个 VEVENT、Content-Type/Disposition 正确;
  把导出的原样导回去 `{"imported":8,"skipped":0,"total":8}`。
- ★ 这次验证**污染了生产库**(那 8 条真写进去了):已按 event_id 逐条删除
  (47 → 39),删除前备份 `/tmp/db-before-cleanup.db`。
  教训:拿生产实例做写侧验证要先想清楚怎么回滚 —— 我这次是先写后想。
- 设备侧:导出选择器实测打开(系统 filemanager 的 `PathPicker`),
  默认文件名正确、目录可选;选中保存后回到日历。
2026-09-18 12:55:46 +08:00